Pass OMP_THREAD_LIMIT to Tesseract subprocesses instead of modifying parent env

Instead of setting OMP_THREAD_LIMIT in the parent process's environment,
calculate the thread limit in the validate hook and pass it through to
Tesseract subprocess calls via the env parameter. This avoids polluting
the parent process's environment while still controlling Tesseract's
thread usage.
This commit is contained in:
James R. Barlow
2026-01-06 18:43:29 -08:00
parent 75c664793e
commit b2b6a7c4b1
3 changed files with 72 additions and 13 deletions
+53 -6
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
import logging
import os
import re
from contextlib import suppress
from enum import IntEnum
@@ -27,6 +28,15 @@ from ocrmypdf.subprocess import get_version, run
log = logging.getLogger(__name__)
def _tesseract_env(omp_thread_limit: int | None) -> dict[str, str] | None:
"""Create environment dict with OMP_THREAD_LIMIT set for Tesseract subprocesses."""
if omp_thread_limit is None:
return None
env = os.environ.copy()
env['OMP_THREAD_LIMIT'] = str(omp_thread_limit)
return env
class ThresholdingMethod(IntEnum):
"""Tesseract thresholding methods for image binarization."""
@@ -166,7 +176,10 @@ def _parse_tesseract_output(binary_output: bytes) -> dict[str, str]:
def get_orientation(
input_file: Path, engine_mode: int | None, timeout: float
input_file: Path,
engine_mode: int | None,
timeout: float,
omp_thread_limit: int | None = None,
) -> OrientationConfidence:
args_tesseract = tess_base_args(['osd'], engine_mode) + [
'--psm',
@@ -176,7 +189,14 @@ def get_orientation(
]
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
except TimeoutExpired:
return OrientationConfidence(angle=0, confidence=0.0)
except CalledProcessError as e:
@@ -210,7 +230,11 @@ def _is_empty_page_error(exc):
def get_deskew(
input_file: Path, languages: list[str], engine_mode: int | None, timeout: float
input_file: Path,
languages: list[str],
engine_mode: int | None,
timeout: float,
omp_thread_limit: int | None = None,
) -> float:
"""Gets angle to deskew this page, in degrees."""
args_tesseract = tess_base_args(languages, engine_mode) + [
@@ -221,7 +245,14 @@ def get_deskew(
]
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
except TimeoutExpired:
return 0.0
except CalledProcessError as e:
@@ -308,6 +339,7 @@ def generate_hocr(
thresholding: ThresholdingMethod,
user_words,
user_patterns,
omp_thread_limit: int | None = None,
) -> None:
"""Generate a hOCR file, which must be converted to PDF."""
prefix = output_hocr.with_suffix('')
@@ -331,7 +363,14 @@ def generate_hocr(
args_tesseract.extend([fspath(input_file), fspath(prefix), 'hocr', 'txt'])
args_tesseract.extend(tessconfig)
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
stdout = p.stdout
except TimeoutExpired:
# Generate a HOCR file with no recognized text if tesseract times out
@@ -374,6 +413,7 @@ def generate_pdf(
thresholding: ThresholdingMethod,
user_words,
user_patterns,
omp_thread_limit: int | None = None,
) -> None:
"""Generate a PDF using Tesseract's internal PDF generator.
@@ -404,7 +444,14 @@ def generate_pdf(
args_tesseract.extend([fspath(input_file), fspath(prefix), 'pdf', 'txt'])
args_tesseract.extend(tessconfig)
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
stdout = p.stdout
with suppress(FileNotFoundError):
prefix.with_suffix('.txt').replace(output_text)
+13 -1
View File
@@ -94,6 +94,13 @@ class TesseractOptions(BaseModel):
user_patterns: Annotated[
str | None, Field(description="Path to Tesseract user patterns file")
] = None
omp_thread_limit: Annotated[
int | None,
Field(
description="Calculated OMP_THREAD_LIMIT for Tesseract subprocesses",
exclude=True,
),
] = None
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'tesseract'):
@@ -334,9 +341,10 @@ def validate(pdfinfo, options):
if not os.environ.get('OMP_THREAD_LIMIT', '').isnumeric():
jobs = options.jobs or available_cpu_count()
tess_threads = clamp(jobs // len(pdfinfo), 1, 3)
os.environ['OMP_THREAD_LIMIT'] = str(tess_threads)
else:
tess_threads = int(os.environ['OMP_THREAD_LIMIT'])
# Store the thread limit in options - it will be passed to subprocess env
options.tesseract.omp_thread_limit = tess_threads
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
if (
@@ -408,6 +416,7 @@ class TesseractOcrEngine(OcrEngine):
input_file,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -417,6 +426,7 @@ class TesseractOcrEngine(OcrEngine):
languages=options.languages,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -433,6 +443,7 @@ class TesseractOcrEngine(OcrEngine):
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -449,6 +460,7 @@ class TesseractOcrEngine(OcrEngine):
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
+6 -6
View File
@@ -23,13 +23,13 @@ from ocrmypdf.exceptions import MissingDependencyError
log = logging.getLogger(__name__)
Args = Sequence[Path | str]
OsEnviron = os._Environ # pylint: disable=protected-access
Environ = Mapping[str, str] | os._Environ # pylint: disable=protected-access
def run(
args: Args,
*,
env: OsEnviron | None = None,
env: Environ | None = None,
logs_errors_to_stdout: bool = False,
check: bool = False,
**kwargs,
@@ -81,7 +81,7 @@ def run_polling_stderr(
*,
callback: Callable[[str], None],
check: bool = False,
env: OsEnviron | None = None,
env: Environ | None = None,
**kwargs,
) -> CompletedProcess:
"""Run a process like ``ocrmypdf.subprocess.run``, and poll stderr.
@@ -116,8 +116,8 @@ def run_polling_stderr(
def _fix_process_args(
args: Args, env: OsEnviron | None, kwargs
) -> tuple[Args, OsEnviron, logging.Logger, bool]:
args: Args, env: Environ | None, kwargs
) -> tuple[Args, Environ, logging.Logger, bool]:
if not env:
env = os.environ
@@ -142,7 +142,7 @@ def get_version(
*,
version_arg: str = '--version',
regex=r'(\d+(\.\d+)*)',
env: OsEnviron | None = None,
env: Environ | None = None,
) -> str:
"""Get the version of the specified program.