概要
前回作った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
■ 翻訳
■ 口調調整(オプション)
■ オーバーレイ表示
■ ローカルLLM管理
動作環境
必要な外部ソフト(任意)
■ Ollama(ローカルLLMを使う場合)
https://ollama.com/
例:
※ Ollamaを使わない場合でも、Google翻訳+Tesseractで動作します。
■ Tesseract OCR(Tesseract使用時)
https://github.com/tesseract-ocr/tesseract
※ EasyOCR / LLM OCRのみ使う場合は不要です。
使い方
終了時は「終了(Ollama解放)」を押すと、ローカルLLMモデルがアンロードされ、VRAMが解放されます。
注意事項
※ ウイルス対策ソフトによっては、画面キャプチャやOCRの挙動により警告が表示される場合がありますが、意図した動作です。
配布について
配布元以外から入手したファイルは使用しないでください。
ライセンス
MIT License に準拠します。
本ソフトウェアは「現状のまま」提供され、作者は使用によって生じたいかなる損害についても責任を負いません。
謝辞
これらの素晴らしいOSSに感謝します。




