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:
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -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: |
|
||||
|
||||
20
build.py
20
build.py
@@ -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:
|
||||
|
||||
@@ -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
23
test/conftest.py
Normal 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
|
||||
Reference in New Issue
Block a user