ゲーム同時翻訳 OCR_Trans_Overlay v0.6.0 ローカル生成AIを実装

概要

前回作ったv0.5.3はOCRの品質がゲーム画面で行うと品質が低く満足が出来ませんでした。今回のv0.6.0ではローカルAIモデルを実装してOCRと翻訳の精度を上げてみます。今回はOCRを使いたい為、画像解析ができるGemma3を使います。精度が良いのは、Gemma3:12bですが、GPUが弱い場合は、Gemma3:4bに下げても良いとは思います。 数ギガバイトあります。このバージョンはGPU搭載のデスクトップPCを推奨します。GPUのないノートパソコンではRAMの消費が激しく動作は厳しかったです。

前回 0.5.3からの変更はapp.pyのコードとOllamaをインストールする点だけです。0.5.3をベースに更新するだけでも利用可能です。

これらのLLMを使う為に、インストールが簡単なOllamaというローカル生成AIアプリを使います。

動作としては、設定パターンはいくつか可能ですが、OCRをEasyOCR、翻訳をGemma3:12bを使うと比較的良さそうです。GPUのスペックに余裕があればOCRもGemma3にするとよいですね。

僕が「探偵の眼」というゲームで試した所、比較的ドットの粗いゲームですが、翻訳はまあまあでした。

また、OCRする中に人名が先頭にあれば、人名をGemma3が類推して女性口調、男性口調というように表現もできるようにしました。

アプリ構成

Pythonで作っています。
OCR、翻訳にローカル生成AIを使いたいのでOllamaをインストールしてください。
https://ollama.com/download
Ollamaをインストールした後は、起動し、gemma3:12bを選択して、チャットするとモデルデータがダウンロードされます。Ollamaで任意のモデルが使える状態にしてください。

一部OCRモードはTesseract OCRを別にインストールして使います。

※ 本ツールは EasyOCR モード により単体で動作します。
※ Tesseract OCR モードもあり、使用する場合は、別途インストールしてください。
Tesseract OCR について
公式サイト:https://github.com/tesseract-ocr/tesseract
インストール後、アプリ内の「tesseract.exe パス」に実行ファイルの場所を指定してください。

ライブラリ

# requirements.txt (v0.6.0) 0.5.3と変更なし

mss>=9.0.1
pytesseract>=0.3.10
Pillow>=10.0.0
PySide6>=6.6.0
deep-translator>=1.11.4

# EasyOCRを使う場合(任意)
easyocr>=1.7.1
torch>=2.0.0
torchvision>=0.15.0

# EasyOCR内部で使用(環境によっては依存で入るが明示)
numpy>=1.26.0

コード

import os
import sys
import json
import io
import base64
import subprocess
import re
from dataclasses import dataclass
from datetime import datetime
from urllib import request

import mss
import pytesseract
from PIL import Image, ImageOps

from deep_translator import GoogleTranslator
from PySide6 import QtCore, QtGui, QtWidgets


def enable_dpi_awareness_windows():
    if sys.platform != "win32":
        return
    try:
        import ctypes
        user32 = ctypes.windll.user32
        user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
    except Exception:
        try:
            import ctypes
            ctypes.windll.user32.SetProcessDPIAware()
        except Exception:
            pass


APP_VERSION = "0.6.0"

DEFAULT_TESSERACT_PATH = r"C:\Program Files\Tesseract-OCR\tesseract.exe"

TICK_INTERVAL_MS = 250
MAX_TEXT_LEN = 1500
OVERLAY_BG_ALPHA = 220

DEBUG_DIR = "debug"
DEBUG_LATEST_PATH = os.path.join(DEBUG_DIR, "latest.png")
DEBUG_LOG_PATH = os.path.join(DEBUG_DIR, "debug_log.txt")

try:
    import easyocr  # type: ignore
    EASYOCR_AVAILABLE = True
except Exception:
    EASYOCR_AVAILABLE = False


@dataclass
class CaptureRect:
    left: int
    top: int
    width: int
    height: int

    def is_valid(self) -> bool:
        return self.width > 5 and self.height > 5


class RegionSelector(QtWidgets.QWidget):
    rectSelected = QtCore.Signal(QtCore.QRect)

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Select Region")
        self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint, True)
        self.setWindowFlag(QtCore.Qt.WindowType.WindowStaysOnTopHint, True)
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
        self.setCursor(QtCore.Qt.CursorShape.CrossCursor)

        vg = QtGui.QGuiApplication.primaryScreen().virtualGeometry()
        self.setGeometry(vg)
        self.showFullScreen()

        self._origin_local: QtCore.QPoint | None = None
        self._current_local: QtCore.QPoint | None = None

    def paintEvent(self, event: QtGui.QPaintEvent) -> None:
        painter = QtGui.QPainter(self)
        painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
        painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 60))

        if self._origin_local and self._current_local:
            r_local = QtCore.QRect(self._origin_local, self._current_local).normalized()
            painter.fillRect(r_local, QtGui.QColor(0, 0, 0, 0))

            pen = QtGui.QPen(QtGui.QColor(0, 180, 255, 220), 2)
            painter.setPen(pen)
            painter.drawRect(r_local)

            text = f"{r_local.width()} x {r_local.height()}"
            painter.setPen(QtGui.QColor(255, 255, 255, 240))
            painter.drawText(r_local.topLeft() + QtCore.QPoint(6, -6), text)

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            self._origin_local = event.position().toPoint()
            self._current_local = self._origin_local
            self.update()

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        if self._origin_local:
            self._current_local = event.position().toPoint()
            self.update()

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
        if event.button() == QtCore.Qt.MouseButton.LeftButton and self._origin_local:
            self._current_local = event.position().toPoint()
            r_local = QtCore.QRect(self._origin_local, self._current_local).normalized()

            tl_global = self.mapToGlobal(r_local.topLeft())
            br_global = self.mapToGlobal(r_local.bottomRight())
            r_global = QtCore.QRect(tl_global, br_global).normalized()

            self._origin_local = None
            self._current_local = None
            self.update()

            self.rectSelected.emit(r_global)
            self.close()

    def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
        if event.key() == QtCore.Qt.Key.Key_Escape:
            self.close()


def preprocess_pixel_pil(img: Image.Image, scale: int = 4, threshold: int = 185) -> Image.Image:
    g = img.convert("L")
    g = g.resize((g.width * scale, g.height * scale), resample=Image.Resampling.NEAREST)
    g = ImageOps.autocontrast(g)
    bw = g.point(lambda p: 255 if p > threshold else 0, mode="1")
    return bw.convert("L")


def tess_ocr(img: Image.Image, lang: str, psm: int, disable_dawg: bool = False, whitelist: str | None = None) -> str:
    cfg = [f"--oem 3 --psm {psm}"]
    if disable_dawg:
        cfg.append("-c load_system_dawg=0")
        cfg.append("-c load_freq_dawg=0")
    if whitelist:
        cfg.append(f"-c tessedit_char_whitelist={whitelist}")
    config = " ".join(cfg)
    return pytesseract.image_to_string(img, lang=lang, config=config)


class OverlayWindow(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Translation Overlay")
        self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint, True)
        self.setWindowFlag(QtCore.Qt.WindowType.WindowStaysOnTopHint, True)
        self.setWindowFlag(QtCore.Qt.WindowType.Tool, True)
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)

        self._dragging = False
        self._drag_offset = QtCore.QPoint(0, 0)
        self._collapsed = False
        self._expanded_size = QtCore.QSize(520, 240)

        self.title = QtWidgets.QLabel("Translation")
        self.title.setStyleSheet("QLabel { color: white; font-weight: 600; }")

        self.btn_collapse = QtWidgets.QToolButton()
        self.btn_collapse.setText("_")
        self.btn_collapse.setToolTip("折りたたみ / 展開")

        self.btn_hide = QtWidgets.QToolButton()
        self.btn_hide.setText("×")
        self.btn_hide.setToolTip("隠す(非表示)")

        self.btn_collapse.clicked.connect(self.toggle_collapse)
        self.btn_hide.clicked.connect(self.hide_overlay)

        bar = QtWidgets.QHBoxLayout()
        bar.setContentsMargins(10, 8, 10, 0)
        bar.addWidget(self.title)
        bar.addStretch(1)
        bar.addWidget(self.btn_collapse)
        bar.addWidget(self.btn_hide)

        self._label = QtWidgets.QLabel("")
        self._label.setWordWrap(True)
        self._label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)

        self.panel = QtWidgets.QFrame()
        self.panel.setStyleSheet(f"""
            QFrame {{
                background: rgba(0, 0, 0, {OVERLAY_BG_ALPHA});
                border: 1px solid rgba(255, 255, 255, 100);
                border-radius: 10px;
            }}
            QLabel {{
                background: transparent;
                color: white;
                font-size: 16px;
            }}
            QToolButton {{
                background: rgba(255,255,255,30);
                color: white;
                border: 1px solid rgba(255,255,255,60);
                border-radius: 6px;
                padding: 2px 6px;
            }}
            QToolButton:hover {{
                background: rgba(255,255,255,60);
            }}
        """)

        panel_layout = QtWidgets.QVBoxLayout(self.panel)
        panel_layout.setContentsMargins(0, 0, 0, 10)
        panel_layout.addLayout(bar)
        panel_layout.addWidget(self._label, 1)
        panel_layout.setSpacing(6)

        outer = QtWidgets.QVBoxLayout(self)
        outer.setContentsMargins(0, 0, 0, 0)
        outer.addWidget(self.panel)

        self.resize(self._expanded_size)
        self._label.setText("(翻訳結果がここに表示されます)")

    def set_text(self, text: str):
        if not self._collapsed:
            self._label.setText(text if text.strip() else "(文字が検出されません)")

    def set_text_cache(self, text: str):
        self._label.setText(text if text.strip() else "(文字が検出されません)")

    def toggle_collapse(self):
        if not self._collapsed:
            self._expanded_size = self.size()
            self._label.setVisible(False)
            self._collapsed = True
            self.btn_collapse.setText("")
            self.resize(self._expanded_size.width(), 44)
        else:
            self._label.setVisible(True)
            self._collapsed = False
            self.btn_collapse.setText("_")
            self.resize(self._expanded_size)

    def hide_overlay(self):
        self.hide()

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            self._dragging = True
            self._drag_offset = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
            event.accept()

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        if self._dragging:
            self.move(event.globalPosition().toPoint() - self._drag_offset)
            event.accept()

    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            self._dragging = False
            event.accept()


def _ollama_call(model: str, base_url: str, system: str, prompt: str, temperature: float,
                 images_b64: list[str] | None = None, timeout_sec: int = 75) -> str:
    payload = {
        "model": model,
        "prompt": prompt,
        "system": system,
        "stream": False,
        "options": {
            "temperature": float(temperature),
            "top_p": 0.9,
            "num_predict": 260,
        },
    }
    if images_b64:
        payload["images"] = images_b64

    url = base_url.rstrip("/") + "/api/generate"
    data = json.dumps(payload).encode("utf-8")
    req = request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")

    with request.urlopen(req, timeout=timeout_sec) as resp:
        body = resp.read().decode("utf-8", errors="replace")
    obj = json.loads(body)
    return (obj.get("response") or "").strip()


def ollama_fix_ocr_text(text: str, lang: str, model: str, base_url: str, timeout_sec: int = 35) -> str:
    if lang == "en":
        system = (
            "You are an OCR post-processor for English game subtitles.\n"
            "Fix OCR errors while preserving meaning. Do NOT add new information.\n"
            "Output ONLY the corrected English text."
        )
    else:
        system = (
            "You are an OCR post-processor for Japanese text.\n"
            "Fix OCR noise while preserving meaning. Do NOT add new information.\n"
            "Output ONLY the corrected Japanese text."
        )
    prompt = f"OCR_TEXT:\n{text}"
    return _ollama_call(model=model, base_url=base_url, system=system, prompt=prompt, temperature=0.05, timeout_sec=timeout_sec)


def _img_to_b64_png(img: Image.Image) -> str:
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return base64.b64encode(buf.getvalue()).decode("utf-8")


def ollama_vision_ocr(img: Image.Image, lang: str, model: str, base_url: str, pixel_hint: bool, timeout_sec: int = 75) -> str:
    work = img.convert("RGB")
    if pixel_hint:
        g = work.convert("L")
        g = g.resize((g.width * 3, g.height * 3), resample=Image.Resampling.NEAREST)
        g = ImageOps.autocontrast(g)
        work = g.convert("RGB")

    max_side = max(work.width, work.height)
    if max_side > 1024:
        scale = 1024 / max_side
        work = work.resize((int(work.width * scale), int(work.height * scale)), resample=Image.Resampling.BILINEAR)

    img_b64 = _img_to_b64_png(work)

    system = "You are a strict OCR engine. Follow the rules exactly."
    prompt = (
        "OCR TASK:\n"
        "Extract the EXACT text from the image.\n"
        "Do NOT translate.\n"
        "Do NOT add commentary.\n"
        "Preserve punctuation.\n"
        "If multiple lines, separate with newlines.\n"
        "Output ONLY the extracted text."
        f"\n\n(language={lang})"
    )

    return _ollama_call(
        model=model,
        base_url=base_url,
        system=system,
        prompt=prompt,
        temperature=0.0,
        images_b64=[img_b64],
        timeout_sec=timeout_sec,
    )


# ---- v0.6.0: 口調推測のための軽量ヒューリスティック ----
_MALE_NAMES = {
    "john", "michael", "david", "james", "robert", "william", "thomas", "daniel",
    "mark", "paul", "kevin", "brian", "george", "peter", "jack", "alexander",
    "ryan", "jason", "matt", "matthew", "chris", "christopher", "andrew",
}
_FEMALE_NAMES = {
    "mary", "linda", "patricia", "jennifer", "elizabeth", "susan", "jessica", "sarah",
    "emily", "anna", "amy", "laura", "victoria", "kate", "katie", "olivia",
    "emma", "chloe", "lily", "grace", "mia",
}


def extract_leading_name(text: str) -> str | None:
    """
    先頭が Name: xxx / Name - xxx / Name — xxx っぽい場合だけNameを抽出
    例:
      "Alice: Hello!" -> "Alice"
      "BOB - OPEN UP!" -> "BOB"
    """
    if not text:
        return None
    first_line = text.strip().splitlines()[0].strip()

    m = re.match(r"^([A-Za-z][A-Za-z0-9_]{1,20})\s*[:\-—]\s+.+", first_line)
    if not m:
        return None
    name = m.group(1).strip()
    # 全部大文字でもOK(ゲーム字幕に多い)
    return name


def guess_gender_from_name(name: str) -> str:
    """
    戻り値: "male" / "female" / "neutral"
    推測できないときは neutral
    """
    if not name:
        return "neutral"
    key = name.strip().lower()
    # まず辞書
    if key in _MALE_NAMES:
        return "male"
    if key in _FEMALE_NAMES:
        return "female"
    # 末尾ヒューリスティック(雑だけど“弱め”に使う)
    if key.endswith(("a", "ia", "na", "elle", "ine", "y")):
        return "female"
    if key.endswith(("o", "us", "er", "son", "man")):
        return "male"
    return "neutral"


def japanese_tone_instruction(tone: str) -> str:
    """
    tone: "male" / "female" / "neutral"
    """
    if tone == "male":
        return (
            "Use a natural casual masculine tone in Japanese, but do not overdo role language.\n"
            "Avoid archaic or exaggerated speech. Do not add new information."
        )
    if tone == "female":
        return (
            "Use a natural casual feminine tone in Japanese, but do not overdo role language.\n"
            "Avoid stereotypical or exaggerated speech. Do not add new information."
        )
    return (
        "Use a natural neutral Japanese tone suitable for subtitles.\n"
        "Do not add new information."
    )


class MainWindow(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(f"OCR → Translate Prototype v{APP_VERSION}")

        self.setMinimumWidth(760)
        self.resize(820, 620)

        self.tess_path = QtWidgets.QLineEdit(DEFAULT_TESSERACT_PATH)

        self.btn_pick = QtWidgets.QPushButton("範囲選択")
        self.btn_start = QtWidgets.QPushButton("開始")
        self.btn_stop = QtWidgets.QPushButton("停止")
        self.btn_stop.setEnabled(False)

        self.btn_overlay_show = QtWidgets.QPushButton("翻訳ウィンドウ表示")
        self.btn_overlay_show.clicked.connect(self.show_overlay)

        self.btn_exit = QtWidgets.QPushButton("Ollama停止(リソース開放)")
        self.btn_exit.clicked.connect(self.exit_with_ollama_cleanup)

        self.chk_debug = QtWidgets.QCheckBox("debug保存")
        self.chk_debug.setChecked(True)

        self.chk_kill_ollama = QtWidgets.QCheckBox("終了時にOllamaプロセスも停止(他アプリに影響)")
        self.chk_kill_ollama.setChecked(False)

        self.tr_mode = QtWidgets.QComboBox()
        self.tr_mode.addItem("EN→JA", ("en", "ja"))
        self.tr_mode.addItem("JA→EN", ("ja", "en"))

        self.tr_engine = QtWidgets.QComboBox()
        self.tr_engine.addItem("Google", "google")
        self.tr_engine.addItem("Ollama", "ollama")

        self.tr_style = QtWidgets.QComboBox()
        self.tr_style.addItem("一般", "general")
        self.tr_style.addItem("ゲーム", "game")

        self.ollama_url = QtWidgets.QLineEdit("http://127.0.0.1:11434")
        self.ollama_model = QtWidgets.QLineEdit("gemma3:4b")

        self.chk_ocr_fix = QtWidgets.QCheckBox("OCR後補正(LLM)")
        self.chk_ocr_fix.setChecked(True)

        self.chk_llm_ocr_pixel_hint = QtWidgets.QCheckBox("LLM OCR ドット補正")
        self.chk_llm_ocr_pixel_hint.setChecked(True)

        self.sp_llm_ocr_ms = QtWidgets.QSpinBox()
        self.sp_llm_ocr_ms.setRange(500, 20000)
        self.sp_llm_ocr_ms.setValue(2500)
        self.sp_llm_ocr_ms.setSuffix("ms")

        self.sp_translate_ms = QtWidgets.QSpinBox()
        self.sp_translate_ms.setRange(500, 20000)
        self.sp_translate_ms.setValue(3500)
        self.sp_translate_ms.setSuffix("ms")

        self.sp_stable_required = QtWidgets.QSpinBox()
        self.sp_stable_required.setRange(0, 10)
        self.sp_stable_required.setValue(2)

        self.ocr_mode = QtWidgets.QComboBox()
        self.ocr_mode.addItem("Tess psm6", "tess_psm6")
        self.ocr_mode.addItem("Tess psm7", "tess_psm7")
        self.ocr_mode.addItem("Tess pixel", "tess_pixel")
        if EASYOCR_AVAILABLE:
            self.ocr_mode.addItem("EasyOCR", "easyocr")
        else:
            self.ocr_mode.addItem("EasyOCR(未)", "easyocr_unavailable")
            self.ocr_mode.model().item(self.ocr_mode.count() - 1).setEnabled(False)
        self.ocr_mode.addItem("LLM OCR", "llm_vision_ocr")

        # ---- v0.6.0: 口調調整(推測)UI ----
        self.chk_tone = QtWidgets.QCheckBox("口調を調整(推測)※EN→JA & Ollamaのみ")
        self.chk_tone.setChecked(False)

        self.cmb_tone = QtWidgets.QComboBox()
        self.cmb_tone.addItem("自動", "auto")
        self.cmb_tone.addItem("男性口調", "male")
        self.cmb_tone.addItem("女性口調", "female")
        self.cmb_tone.addItem("中立", "neutral")
        self.cmb_tone.setEnabled(False)

        self.chk_tone.toggled.connect(lambda on: self.cmb_tone.setEnabled(bool(on)))

        self.rect_label = QtWidgets.QLabel("未選択")
        self.status = QtWidgets.QLabel("待機中")

        # OCR/補正テキスト欄(既定で表示ON)
        self.grp_text = QtWidgets.QGroupBox("OCRテキスト(開閉できます)")
        self.grp_text.setCheckable(True)
        self.grp_text.setChecked(True)

        self.raw_text = QtWidgets.QPlainTextEdit()
        self.raw_text.setReadOnly(True)
        self.raw_text.setMinimumHeight(90)

        self.fixed_text = QtWidgets.QPlainTextEdit()
        self.fixed_text.setReadOnly(True)
        self.fixed_text.setMinimumHeight(90)

        text_split = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal)
        left = QtWidgets.QWidget()
        lyt = QtWidgets.QVBoxLayout(left)
        lyt.setContentsMargins(0, 0, 0, 0)
        lyt.addWidget(QtWidgets.QLabel("OCR原文"))
        lyt.addWidget(self.raw_text)

        right = QtWidgets.QWidget()
        ryt = QtWidgets.QVBoxLayout(right)
        ryt.setContentsMargins(0, 0, 0, 0)
        ryt.addWidget(QtWidgets.QLabel("補正後(翻訳入力)"))
        ryt.addWidget(self.fixed_text)

        text_split.addWidget(left)
        text_split.addWidget(right)
        text_split.setStretchFactor(0, 1)
        text_split.setStretchFactor(1, 1)

        grp_layout = QtWidgets.QVBoxLayout(self.grp_text)
        grp_layout.addWidget(text_split)

        # ---- UIレイアウト(2列+口調)----
        grid = QtWidgets.QGridLayout()
        grid.setHorizontalSpacing(10)
        grid.setVerticalSpacing(6)

        r = 0
        grid.addWidget(QtWidgets.QLabel("翻訳"), r, 0)
        grid.addWidget(self.tr_mode, r, 1)
        grid.addWidget(QtWidgets.QLabel("エンジン"), r, 2)
        grid.addWidget(self.tr_engine, r, 3)
        r += 1

        grid.addWidget(QtWidgets.QLabel("スタイル"), r, 0)
        grid.addWidget(self.tr_style, r, 1)
        grid.addWidget(QtWidgets.QLabel("OCR"), r, 2)
        grid.addWidget(self.ocr_mode, r, 3)
        r += 1

        grid.addWidget(self.chk_ocr_fix, r, 0, 1, 2)
        grid.addWidget(self.chk_llm_ocr_pixel_hint, r, 2, 1, 2)
        r += 1

        grid.addWidget(QtWidgets.QLabel("LLM OCR間隔"), r, 0)
        grid.addWidget(self.sp_llm_ocr_ms, r, 1)
        grid.addWidget(QtWidgets.QLabel("翻訳間隔"), r, 2)
        grid.addWidget(self.sp_translate_ms, r, 3)
        r += 1

        grid.addWidget(QtWidgets.QLabel("安定回数"), r, 0)
        grid.addWidget(self.sp_stable_required, r, 1)
        grid.addWidget(self.chk_debug, r, 2)
        grid.addWidget(self.btn_overlay_show, r, 3)
        r += 1

        # 口調
        grid.addWidget(self.chk_tone, r, 0, 1, 3)
        grid.addWidget(self.cmb_tone, r, 3)
        r += 1

        grid.addWidget(self.chk_kill_ollama, r, 0, 1, 4)
        r += 1

        grid.addWidget(QtWidgets.QLabel("Ollama URL"), r, 0)
        grid.addWidget(self.ollama_url, r, 1, 1, 3)
        r += 1

        grid.addWidget(QtWidgets.QLabel("Model"), r, 0)
        grid.addWidget(self.ollama_model, r, 1, 1, 3)
        r += 1

        grid.addWidget(QtWidgets.QLabel("Tesseract"), r, 0)
        grid.addWidget(self.tess_path, r, 1, 1, 3)
        r += 1

        grid.addWidget(QtWidgets.QLabel("範囲"), r, 0)
        grid.addWidget(self.rect_label, r, 1, 1, 3)

        btn_row = QtWidgets.QHBoxLayout()
        btn_row.addWidget(self.btn_pick)
        btn_row.addStretch(1)
        btn_row.addWidget(self.btn_start)
        btn_row.addWidget(self.btn_stop)
        btn_row.addWidget(self.btn_exit)

        layout = QtWidgets.QVBoxLayout(self)
        layout.addLayout(grid)
        layout.addLayout(btn_row)
        layout.addWidget(QtWidgets.QLabel("ステータス"))
        layout.addWidget(self.status)
        layout.addWidget(self.grp_text)

        self.overlay = OverlayWindow()
        self.overlay.show()

        self.timer = QtCore.QTimer(self)
        self.timer.setInterval(TICK_INTERVAL_MS)
        self.timer.timeout.connect(self.tick)

        self._busy = False
        self._last_ocr = ""
        self._last_fixed = ""
        self._last_translated_source = ""
        self._stable_count = 0
        self._last_llm_ocr_at = datetime.min
        self._last_translate_at = datetime.min

        self.selector: RegionSelector | None = None
        self.selected_qt_global: QtCore.QRect | None = None
        self.mss_virtual = None
        self._easy_readers = {}
        self._last_overlay_text = ""

        self.btn_pick.clicked.connect(self.pick_region)
        self.btn_start.clicked.connect(self.start)
        self.btn_stop.clicked.connect(self.stop)

        self.grp_text.toggled.connect(self.on_group_toggled)
        self.on_group_toggled(self.grp_text.isChecked())

    def closeEvent(self, event: QtGui.QCloseEvent) -> None:
        try:
            self.exit_with_ollama_cleanup(quit_app=False)
        except Exception:
            pass
        event.accept()

    def on_group_toggled(self, checked: bool):
        self.grp_text.setVisible(checked)
        self.adjustSize()

    def show_overlay(self):
        self.overlay.show()
        self.overlay.raise_()
        self.overlay.activateWindow()
        self.overlay.set_text_cache(self._last_overlay_text or "(翻訳結果がここに表示されます)")

    def ensure_debug_dir(self):
        if self.chk_debug.isChecked():
            os.makedirs(DEBUG_DIR, exist_ok=True)

    def log_debug(self, msg: str):
        if not self.chk_debug.isChecked():
            return
        self.ensure_debug_dir()
        line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
        try:
            with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
                f.write(line)
        except Exception:
            pass

    def unload_ollama_model(self):
        model = self.ollama_model.text().strip()
        if not model:
            return
        try:
            r = subprocess.run(["ollama", "stop", model], capture_output=True, text=True, timeout=12)
            self.log_debug(f"ollama stop {model} rc={r.returncode} out={r.stdout.strip()} err={r.stderr.strip()}")
        except FileNotFoundError:
            self.log_debug("ollama command not found. PATHにollamaが必要です。")
        except Exception as e:
            self.log_debug(f"ollama stop failed: {e}")

    def kill_ollama_process(self):
        if sys.platform != "win32":
            return
        try:
            r = subprocess.run(["taskkill", "/IM", "ollama.exe", "/F"], capture_output=True, text=True, timeout=12)
            self.log_debug(f"taskkill ollama.exe rc={r.returncode} out={r.stdout.strip()} err={r.stderr.strip()}")
        except Exception as e:
            self.log_debug(f"taskkill failed: {e}")

    def exit_with_ollama_cleanup(self, quit_app: bool = True):
        try:
            self.stop()
        except Exception:
            pass

        self.status.setText("終了処理中…(Ollama解放)")
        QtWidgets.QApplication.processEvents()
        self.unload_ollama_model()

        if self.chk_kill_ollama.isChecked():
            self.kill_ollama_process()

        if quit_app:
            QtWidgets.QApplication.quit()

    def pick_region(self):
        self.status.setText("範囲選択中(ドラッグ / ESCでキャンセル)")
        self.selector = RegionSelector()
        self.selector.rectSelected.connect(self.on_region_selected)
        self.selector.destroyed.connect(lambda: setattr(self, "selector", None))
        self.selector.show()

    def on_region_selected(self, r_qt_global: QtCore.QRect):
        self.selected_qt_global = r_qt_global
        self.rect_label.setText(
            f"({r_qt_global.left()},{r_qt_global.top()}) {r_qt_global.width()}x{r_qt_global.height()}"
        )
        self.status.setText("範囲が選択されました。開始できます。")

    def start(self):
        tp = self.tess_path.text().strip()
        if tp and os.path.exists(tp):
            pytesseract.pytesseract.tesseract_cmd = tp

        if not self.selected_qt_global or self.selected_qt_global.width() < 6 or self.selected_qt_global.height() < 6:
            self.status.setText("処理範囲が未選択です。")
            return

        with mss.mss() as sct:
            self.mss_virtual = sct.monitors[0]

        self.ensure_debug_dir()

        self._last_ocr = ""
        self._last_fixed = ""
        self._last_translated_source = ""
        self._stable_count = 0
        self._last_llm_ocr_at = datetime.min
        self._last_translate_at = datetime.min
        self._last_overlay_text = ""

        self.btn_start.setEnabled(False)
        self.btn_stop.setEnabled(True)
        self.btn_pick.setEnabled(False)

        self.timer.start()
        self.status.setText(f"実行中… v{APP_VERSION}")

    def stop(self):
        self.timer.stop()
        self.btn_start.setEnabled(True)
        self.btn_stop.setEnabled(False)
        self.btn_pick.setEnabled(True)
        self.status.setText("停止しました。")

    def _ollama_ready(self) -> tuple[str, str]:
        base_url = self.ollama_url.text().strip() or "http://127.0.0.1:11434"
        model = self.ollama_model.text().strip() or "gemma3:4b"
        return base_url, model

    def get_google_translator(self):
        src, tgt = self.tr_mode.currentData()
        return GoogleTranslator(source=src, target=tgt)

    def _tone_for_current_text(self, text: str) -> str:
        """戻り値: 'male'/'female'/'neutral'"""
        mode_src, mode_tgt = self.tr_mode.currentData()
        if not (mode_src == "en" and mode_tgt == "ja"):
            return "neutral"
        if not self.chk_tone.isChecked():
            return "neutral"

        sel = self.cmb_tone.currentData()
        if sel in ("male", "female", "neutral"):
            return sel

        # auto
        name = extract_leading_name(text)
        if not name:
            return "neutral"
        g = guess_gender_from_name(name)
        return g

    def translate_text(self, text: str) -> str:
        src, tgt = self.tr_mode.currentData()
        engine = self.tr_engine.currentData()
        style = self.tr_style.currentData()

        if engine == "google":
            return self.get_google_translator().translate(text)

        base_url, model = self._ollama_ready()

        # ---- v0.6.0: 口調指示(EN→JA & Ollamaのみ)----
        tone = self._tone_for_current_text(text)
        tone_hint = japanese_tone_instruction(tone)

        # styleによる温度
        if style == "game":
            temp = 0.25
        else:
            temp = 0.10

        system = (
            "You are a translator for on-screen subtitles.\n"
            "Output ONLY the translation. Do not add commentary.\n"
            "Preserve meaning accurately.\n"
            + tone_hint
        )

        prompt = f"Translate from {src} to {tgt}.\n\nTEXT:\n{text}"
        return _ollama_call(model=model, base_url=base_url, system=system, prompt=prompt, temperature=temp, timeout_sec=75)

    def get_easy_reader(self, src_lang: str):
        if src_lang not in self._easy_readers:
            self.status.setText("EasyOCR初期化中…")
            QtWidgets.QApplication.processEvents()
            self._easy_readers[src_lang] = easyocr.Reader([src_lang], gpu=False)
        return self._easy_readers[src_lang]

    def qt_rect_to_mss_rect(self, r_qt_global: QtCore.QRect) -> CaptureRect:
        assert self.mss_virtual is not None
        qt_vg = QtGui.QGuiApplication.primaryScreen().virtualGeometry()
        mv = self.mss_virtual

        scale_x = mv["width"] / qt_vg.width()
        scale_y = mv["height"] / qt_vg.height()

        left = int((r_qt_global.left() - qt_vg.left()) * scale_x + mv["left"])
        top = int((r_qt_global.top() - qt_vg.top()) * scale_y + mv["top"])
        width = int(r_qt_global.width() * scale_x)
        height = int(r_qt_global.height() * scale_y)
        return CaptureRect(left, top, width, height)

    @staticmethod
    def capture_region(rect: CaptureRect) -> Image.Image:
        with mss.mss() as sct:
            monitor = {"left": rect.left, "top": rect.top, "width": rect.width, "height": rect.height}
            shot = sct.grab(monitor)
            return Image.frombytes("RGB", shot.size, shot.rgb)

    def _ms_since(self, t: datetime) -> int:
        return int((datetime.now() - t).total_seconds() * 1000)

    def tick(self):
        if self._busy:
            return
        self._busy = True
        try:
            if not self.selected_qt_global or not self.mss_virtual:
                return

            cap = self.qt_rect_to_mss_rect(self.selected_qt_global)
            if not cap.is_valid():
                return

            img = self.capture_region(cap)

            if self.chk_debug.isChecked():
                self.ensure_debug_dir()
                try:
                    img.save(DEBUG_LATEST_PATH)
                except Exception:
                    pass

            src, _tgt = self.tr_mode.currentData()
            mode = self.ocr_mode.currentData()
            base_url, model = self._ollama_ready()

            ocr_text = self._last_ocr

            if mode == "llm_vision_ocr":
                if self._ms_since(self._last_llm_ocr_at) >= int(self.sp_llm_ocr_ms.value()):
                    try:
                        self.status.setText("LLM OCR…")
                        QtWidgets.QApplication.processEvents()
                        ocr_text = ollama_vision_ocr(
                            img=img,
                            lang=src,
                            model=model,
                            base_url=base_url,
                            pixel_hint=self.chk_llm_ocr_pixel_hint.isChecked(),
                            timeout_sec=75,
                        )
                        self._last_llm_ocr_at = datetime.now()
                    except Exception as e:
                        self.log_debug(f"LLM OCR error: {e}")
                        ocr_text = self._last_ocr
            else:
                tess_lang = "eng" if src == "en" else "jpn"
                if mode == "tess_psm6":
                    ocr_text = tess_ocr(img.convert("L"), lang=tess_lang, psm=6)
                elif mode == "tess_psm7":
                    ocr_text = tess_ocr(img.convert("L"), lang=tess_lang, psm=7)
                elif mode == "tess_pixel":
                    pre = preprocess_pixel_pil(img, scale=4, threshold=185)
                    if src == "en":
                        whitelist = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,!?;:'\"-()[] "
                        ocr_text = tess_ocr(pre, lang="eng", psm=7, disable_dawg=True, whitelist=whitelist)
                    else:
                        ocr_text = tess_ocr(pre, lang="jpn", psm=7, disable_dawg=True)
                elif mode == "easyocr":
                    reader = self.get_easy_reader(src)
                    import numpy as _np
                    arr = _np.array(img.convert("RGB"))
                    results = reader.readtext(arr, detail=0, paragraph=True)
                    ocr_text = "\n".join(results)

            ocr_text = (ocr_text or "").strip()[:MAX_TEXT_LEN].strip()

            if ocr_text and ocr_text == self._last_ocr:
                self._stable_count += 1
            elif ocr_text and ocr_text != self._last_ocr:
                self._stable_count = 0

            if ocr_text != self._last_ocr:
                self._last_ocr = ocr_text
                if self.grp_text.isChecked():
                    self.raw_text.setPlainText(ocr_text)

            if not ocr_text:
                self._last_overlay_text = "(文字が検出されません)"
                self.overlay.set_text_cache(self._last_overlay_text)
                self.overlay.set_text(self._last_overlay_text)
                return

            fixed = ocr_text
            if self.chk_ocr_fix.isChecked():
                try:
                    fixed2 = ollama_fix_ocr_text(
                        text=ocr_text,
                        lang=src,
                        model=model,
                        base_url=base_url,
                        timeout_sec=35,
                    )
                    if fixed2.strip():
                        fixed = fixed2.strip()
                except Exception as e:
                    self.log_debug(f"OCR fix error: {e}")
                    fixed = ocr_text

            if fixed != self._last_fixed:
                self._last_fixed = fixed
                if self.grp_text.isChecked():
                    self.fixed_text.setPlainText(fixed)

            stable_required = int(self.sp_stable_required.value())
            stable_ok = (stable_required == 0) or (self._stable_count >= stable_required)

            if stable_ok and self._ms_since(self._last_translate_at) >= int(self.sp_translate_ms.value()):
                if fixed and fixed != self._last_translated_source:
                    self.status.setText("翻訳…")
                    QtWidgets.QApplication.processEvents()
                    try:
                        translated = self.translate_text(fixed)
                    except Exception as e:
                        translated = f"翻訳エラー: {e}"

                    self._last_translate_at = datetime.now()
                    self._last_translated_source = fixed
                    self._last_overlay_text = translated
                    self.overlay.set_text_cache(translated)
                    self.overlay.set_text(translated)

            tone_state = "OFF"
            if self.chk_tone.isChecked():
                tone_state = self.cmb_tone.currentData()
            self.status.setText(f"実行中 OCR={mode} model={model} 安定={self._stable_count} 口調={tone_state}")

        finally:
            self._busy = False


def main():
    enable_dpi_awareness_windows()
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

ダウンロード

レンタルサーバだとウイルス検出等不安があるため、Googleドライブで配布します。
次のリンク先からzipをダウンロードし、zip回答して app.exeを起動してください。
ocr_translate_overlay
https://drive.google.com/drive/folders/1OUL66DKSsvZVrW8y-2aVFLTl2zpiQUjf?usp=drive_link

追加インストールが必要なTesseract OCR について
本ツールで Tesseract OCR モードを使用する場合は、
別途 Tesseract OCR をインストールしてください。
公式サイト:
https://github.com/tesseract-ocr/tesseract
インストール後、アプリ内の
「tesseract.exe パス」に実行ファイルの場所を指定してください。

Ollama
https://ollama.com/download
Ollamaをインストールした後は、起動し、gemma3:12bを選択して、チャットするとモデルデータがダウンロードされます。Ollamaで任意のモデルが使える状態にしてください。

使い方

OCR Translate Overlay v0.6.0
画面OCR+翻訳 オーバーレイツール(Windows)

翻訳中、少し画面操作の反応が悪くなります。その時は、停止ボタンを押してください。リアルタイム翻訳が停止します。

■ このツールについて

OCR Translate Overlay v0.6.0

画面上の文字をリアルタイムでOCRし、翻訳結果を透明なオーバーレイで表示する
Windows向けデスクトップアプリです。

ゲーム、動画、PDF、Webページなど、コピーできない文字の翻訳を想定しています。

主な機能

■ 画面OCR

  • 任意の画面範囲をドラッグで指定
  • 以下のOCR方式を切り替え可能
  • Tesseract OCR(複数モード)
  • EasyOCR
  • ローカルLLM OCR(Ollama / Vision対応モデル)

■ 翻訳

  • 英語 → 日本語
  • 日本語 → 英語
  • 翻訳エンジン切替
  • Google翻訳(オンライン)
  • ローカルLLM翻訳(Ollama)

■ 口調調整(オプション)

  • EN→JA + Ollama翻訳時のみ有効
  • 人名が先頭にある場合に口調を推測
  • 男性口調 / 女性口調 / 中立
  • 既定では OFF(推測ミス防止のため)

■ オーバーレイ表示

  • 常に最前面表示
  • ドラッグで移動可能
  • 折りたたみ / 非表示ボタンあり
  • タスクバーに常駐しない軽量表示

■ ローカルLLM管理

  • Ollamaモデルのアンロード(VRAM解放)ボタンあり
  • 必要に応じてOllamaプロセス強制終了(オプション)

動作環境

  • Windows 10 / 11(64bit)
  • GPU搭載環境推奨(ローカルLLM使用時)
  • Python環境は不要(exe版の場合)

必要な外部ソフト(任意)

■ Ollama(ローカルLLMを使う場合)
https://ollama.com/

例:

  • gemma3:4b
  • gemma3:27b(高精度・VRAM消費大)
  • llama3.x 系 など

※ Ollamaを使わない場合でも、Google翻訳+Tesseractで動作します。

■ Tesseract OCR(Tesseract使用時)
https://github.com/tesseract-ocr/tesseract

※ EasyOCR / LLM OCRのみ使う場合は不要です。

使い方

  1. アプリを起動
  2. 「範囲選択」をクリックし、翻訳したい画面領域をドラッグ
  3. OCR方式・翻訳方式を選択
  4. 「開始」を押す
  5. 翻訳結果が画面上にオーバーレイ表示されます

終了時は「終了(Ollama解放)」を押すと、ローカルLLMモデルがアンロードされ、VRAMが解放されます。

注意事項

  • 本アプリは「画面キャプチャ」を行いますが、
    キャプチャされるのはユーザーが指定した範囲のみです。
  • Google翻訳を使用する場合、翻訳対象テキストは外部に送信されます。
  • Ollama使用時は、翻訳・OCR処理はすべてローカルで完結します。
  • 個人情報の収集・送信は行いません。

※ ウイルス対策ソフトによっては、画面キャプチャやOCRの挙動により警告が表示される場合がありますが、意図した動作です。

配布について

  • 本アプリはコード署名されていません。
  • 初回起動時、Windows SmartScreen の警告が表示される場合があります。
    「詳細情報」→「実行」で起動できます。

配布元以外から入手したファイルは使用しないでください。

ライセンス

MIT License に準拠します。
本ソフトウェアは「現状のまま」提供され、作者は使用によって生じたいかなる損害についても責任を負いません。

謝辞

  • Tesseract OCR
  • EasyOCR
  • Ollama
  • Google Translate
  • PySide6
  • PyInstaller

これらの素晴らしいOSSに感謝します。