ocr: bundle Tesseract into release for click-and-run OCR

Make the standalone exe work with OCR out of the box — no separate
Tesseract install, no tesserocr DLL hell. Verified end-to-end: built the
exe, ran it frozen with the system Tesseract blinded, confirmed it
resolves the bundled binary and reads text ("CHAM RUNE").

- ocr.py: resolve an _APP_BASE (exe dir when frozen, else cwd) and prefer
  a bundled <exe_dir>/tesseract/tesseract.exe over PATH / Program Files.
  Resolve assets/tessdata to an absolute path so OCR no longer depends on
  the current working dir. Applies to both the tesserocr and pytesseract
  paths.
- build.py: copy a portable Tesseract (exe + DLLs) from TESSERACT_DIR
  (default C:\Program Files\Tesseract-OCR) into <release>/tesseract/. Our
  trained models in assets/tessdata are used via --tessdata-dir, so their
  tessdata is skipped. Warns (non-fatal) if Tesseract isn't present.
- ci.yml: choco install tesseract before the build so the bundle is
  reproducible on the runner; verify it landed in the release dir.
- test/conftest.py: apply the SSL cert-store workaround so pytest can be
  collected on Windows boxes with a corrupted cert store (no-op on CI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-06-20 10:49:25 +02:00
parent f31c30f388
commit 3d11947bf2
4 changed files with 94 additions and 8 deletions

View File

@@ -79,6 +79,15 @@ jobs:
environment-file: environment-win11.yml
use-only-tar-bz2: false
- name: Install Tesseract (bundled into the release for click-and-run OCR)
shell: powershell
run: |
$ErrorActionPreference = "Stop"
choco install tesseract --no-progress -y
if (-not (Test-Path "C:\Program Files\Tesseract-OCR\tesseract.exe")) {
throw "Tesseract install did not produce tesseract.exe"
}
- name: Build exe
shell: powershell
env:
@@ -88,6 +97,15 @@ jobs:
C:\Miniconda\condabin\conda.bat activate botty
python build.py --conda_path C:\Miniconda
- name: Verify Tesseract was bundled
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
$tess = Join-Path $BOTTY_DIR "tesseract\tesseract.exe"
if (-not (Test-Path $tess)) { throw "Tesseract was not bundled into $BOTTY_DIR" }
Write-Host "Bundled: $tess"
- name: Launch smoke test (built executables)
shell: powershell
run: |

View File

@@ -115,6 +115,26 @@ if __name__ == "__main__":
shutil.copy("run.bat", f"{botty_dir}/")
if os.path.exists("dependencies"):
shutil.copytree("dependencies", f"{botty_dir}/dependencies")
# Bundle a portable Tesseract so the standalone exe is click-and-run with
# working OCR and no separate install. ocr.py prefers <exe_dir>/tesseract/
# tesseract.exe. Source: TESSERACT_DIR env or the default UB Mannheim path.
# Skipped (with a warning) if not present — the bot still works once the
# user runs install.bat, which sets OCR up the conda way.
tesseract_src = os.environ.get("TESSERACT_DIR", r"C:\Program Files\Tesseract-OCR")
tess_exe = os.path.join(tesseract_src, "tesseract.exe")
if os.path.isfile(tess_exe):
print(f"Bundling Tesseract from {tesseract_src}")
# Copy the exe + DLLs; skip their tessdata (we ship our own trained
# models in assets/tessdata and pass --tessdata-dir to point at them).
os.makedirs(f"{botty_dir}/tesseract", exist_ok=True)
for entry in os.listdir(tesseract_src):
src = os.path.join(tesseract_src, entry)
if os.path.isfile(src) and entry.lower().endswith((".exe", ".dll")):
shutil.copy(src, f"{botty_dir}/tesseract/")
else:
print(f"WARNING: Tesseract not found at {tesseract_src} — release will "
f"rely on install.bat for OCR setup. Set TESSERACT_DIR to bundle it.")
clean_up()
if args.random_name:

View File

@@ -1,5 +1,24 @@
import os
import sys
# Application base dir: the folder the exe lives in when frozen (PyInstaller),
# otherwise the current working dir (repo root when running `python src/main.py`).
# Used to locate the bundled tesseract + assets/tessdata regardless of cwd.
if getattr(sys, "frozen", False):
_APP_BASE = os.path.dirname(sys.executable)
else:
_APP_BASE = os.getcwd()
def _resolve_under_base(rel_path: str) -> str:
"""Return an absolute path to rel_path under the app base, else rel_path as-is."""
candidate = os.path.join(_APP_BASE, rel_path)
return candidate if os.path.exists(candidate) else rel_path
# Absolute tessdata dir so OCR works no matter what the current working dir is.
TESSDATA_DIR = _resolve_under_base("assets/tessdata")
if os.name == 'nt':
# Register conda DLL dirs so Python's .pyd loader (LOAD_LIBRARY_SEARCH_USER_DIRS)
# can find tesserocr's dependencies (tesseract, leptonica, etc.)
@@ -18,12 +37,18 @@ except Exception:
try:
import pytesseract
# pytesseract only looks for plain "tesseract" on PATH; it does not read
# PYTESSERACT_TESSERACT_CMD (set by run_botty.bat), so apply it here.
# PYTESSERACT_TESSERACT_CMD (set by run_botty.bat), so resolve the binary
# here. Search order prefers the tesseract bundled in the release (so the
# standalone exe is click-and-run with no separate tesseract install).
import shutil
_cmd = os.environ.get("PYTESSERACT_TESSERACT_CMD")
if not (_cmd and os.path.isfile(_cmd)):
_cmd = shutil.which("tesseract") or r"C:\Program Files\Tesseract-OCR\tesseract.exe"
if os.path.isfile(_cmd):
_candidates = [
os.environ.get("PYTESSERACT_TESSERACT_CMD"),
os.path.join(_APP_BASE, "tesseract", "tesseract.exe"), # bundled in release
shutil.which("tesseract"),
r"C:\Program Files\Tesseract-OCR\tesseract.exe",
]
_cmd = next((c for c in _candidates if c and os.path.isfile(c)), None)
if _cmd:
pytesseract.pytesseract.tesseract_cmd = _cmd
else:
pytesseract = None
@@ -89,8 +114,8 @@ def image_to_text(
check_known_errors=check_known_errors, correct_words=correct_words
)
with PyTessBaseAPI(psm=psm, oem=OEM.LSTM_ONLY, path=f"assets/tessdata", lang=model ) as api:
api.ReadConfigFile("assets/tessdata/ocr_config.txt")
with PyTessBaseAPI(psm=psm, oem=OEM.LSTM_ONLY, path=TESSDATA_DIR, lang=model ) as api:
api.ReadConfigFile(os.path.join(TESSDATA_DIR, "ocr_config.txt"))
if word_list:
api.SetVariable("user_words_file", word_list)
#api.SetSourceResolution(72 * scale)
@@ -172,7 +197,7 @@ def _image_to_text_pytesseract(
if digits_only:
custom_config += " -c tessedit_char_whitelist=0123456789"
tessdata_dir = "assets/tessdata"
tessdata_dir = TESSDATA_DIR
results = []
for image in images:

23
test/conftest.py Normal file
View File

@@ -0,0 +1,23 @@
# Apply the same SSL cert-store workaround the bot entry points use, so the
# test suite can be collected on Windows machines whose cert store is corrupted.
# Importing the bot code pulls in discord/aiohttp, which calls
# ssl.create_default_context() at import time; a malformed Windows cert raises
# ssl.SSLError: [ASN1: NOT_ENOUGH_DATA]. No-op on a healthy cert store (incl. CI).
import sys
if sys.platform == "win32":
import ssl
_orig_load_default_certs = ssl.SSLContext.load_default_certs
def _safe_load_default_certs(self, purpose=ssl.Purpose.CLIENT_AUTH):
try:
_orig_load_default_certs(self, purpose)
except ssl.SSLError:
try:
import certifi
self.load_verify_locations(certifi.where())
except Exception:
pass
ssl.SSLContext.load_default_certs = _safe_load_default_certs