fix: make OCR portable and reliable across AMD64 and ARM64 (#519)

* fix: make OCR portable and reliable

* fix: harden OCR installation portability

* fix: pin OCR partials across downloads

* fix: make OCR execution reliably asynchronous

* fix: harden OCR portability and docs routes

* fix: preserve decoder and docs safeguards
This commit is contained in:
SnapOtter
2026-07-15 03:34:24 +08:00
committed by GitHub
parent 58121f205f
commit 991c981529
409 changed files with 67151 additions and 8076 deletions
-4
View File
@@ -66,8 +66,6 @@ ALLOWED_SCRIPTS = {
"inpaint",
"install_feature",
"noise_removal",
"ocr",
"ocr_pdf",
"ocr_preprocess",
"outpaint",
"red_eye_removal",
@@ -122,8 +120,6 @@ TOOL_BUNDLE_MAP = {
"enhance_faces": "upscale-enhance",
"noise_removal": "upscale-enhance",
"restore": "photo-restoration",
"ocr": "ocr",
"ocr_pdf": "ocr",
"transcribe": "transcription",
}
+11 -43
View File
@@ -36,10 +36,10 @@ def gpu_available():
"""Return True if a usable CUDA GPU is present at runtime.
This is the general "can any framework use a GPU" check (torch, then ONNX
Runtime, then paddle). Tools bound to a single framework should instead call
Runtime). Tools bound to a single framework should instead call
the matching per-framework helper (torch_gpu_available,
ctranslate2_gpu_available) so a GPU that only paddle or ONNX can use is not
mistaken for a torch GPU.
ctranslate2_gpu_available) so a GPU that only ONNX can use is not mistaken
for a torch GPU.
"""
if _override_disables_gpu():
return False
@@ -55,16 +55,14 @@ def gpu_available():
if onnx_available:
return True
# A GPU is physically present but neither torch nor ONNX Runtime can use it.
# The OCR bundle ships paddlepaddle-gpu, which still can, so probe paddle in
# an isolated subprocess. This runs only now that nvidia-smi confirms a GPU,
# and never in-process, because a GPU-less paddle import segfaults.
# A GPU is physically present but neither supported framework can use it.
# OCR is now an isolated portable CPU ONNX runtime, so Paddle must never be
# imported as a fallback probe here (the old GPU wheel could crash while
# resolving libcuda on otherwise valid CPU-only hosts).
gpu_name = _nvidia_smi_gpu_name()
if gpu_name:
if _try_paddle_cuda_subprocess():
return True
print(f"[gpu] nvidia-smi found GPU ({gpu_name}) but neither torch, ONNX "
"Runtime, nor paddle can use it; reinstall AI features for GPU support",
print(f"[gpu] nvidia-smi found GPU ({gpu_name}) but neither torch nor ONNX "
"Runtime can use it; reinstall the relevant AI feature for GPU support",
file=sys.stderr, flush=True)
return False
@@ -129,42 +127,12 @@ def _try_onnx_cuda():
return False
def _try_paddle_cuda_subprocess():
"""Check GPU via paddle in an isolated subprocess.
The OCR bundle ships paddlepaddle-gpu with no torch or ONNX Runtime, so paddle
is the only framework that can see the GPU on an OCR-only host. Importing
paddlepaddle-gpu in-process segfaults on a GPU-less machine, so this runs in a
throwaway subprocess and callers must confirm a GPU is present (via nvidia-smi)
before invoking it. Returns True only when paddle has a CUDA build and a
visible GPU. The result is signalled through the exit code so paddle's own
import chatter on stdout cannot corrupt the reading.
"""
probe = (
"import paddle, sys; "
"sys.exit(0 if (paddle.is_compiled_with_cuda() "
"and paddle.device.cuda.device_count() > 0) else 1)"
)
try:
result = subprocess.run(
[sys.executable, "-c", probe],
capture_output=True, text=True, timeout=30,
)
except (OSError, subprocess.SubprocessError):
return False
if result.returncode == 0:
print("[gpu] CUDA available via paddle (paddlepaddle-gpu)",
file=sys.stderr, flush=True)
return True
return False
def torch_gpu_available():
"""True iff torch itself can use CUDA (honors the SNAPOTTER_GPU override).
Torch-based tools (upscale, denoise, face enhancement, restore) must gate on
this rather than gpu_available(), which can report True based on paddle or
ONNX Runtime while torch is a CPU-only build. Routing those tools to CUDA on a
this rather than gpu_available(), which can report True based on ONNX Runtime
while torch is a CPU-only build. Routing those tools to CUDA on a
device torch cannot use would crash them.
"""
if _override_disables_gpu():
File diff suppressed because it is too large Load Diff
-435
View File
@@ -1,435 +0,0 @@
"""Text extraction from images using Tesseract, PaddleOCR PP-OCRv5, or PaddleOCR-VL 1.5."""
import sys
import json
import os
# Prevent PaddlePaddle C++ runtime from probing for CUDA on CPU-only systems.
# Without these, paddlepaddle-gpu can segfault during import on machines without
# a GPU, because the C++ layer attempts GPU initialization before Python-level
# device routing takes effect. Must run before any PaddleOCR import.
from gpu import gpu_available
if not gpu_available():
if not os.environ.get("FLAGS_use_cuda"):
os.environ["FLAGS_use_cuda"] = "0"
if not os.environ.get("FLAGS_use_cudnn"):
os.environ["FLAGS_use_cudnn"] = "0"
# Lazy-loaded VLM instance (stays resident in dispatcher process)
_paddleocr_vl_instance = None
def emit_progress(percent, stage):
"""Emit structured progress to stderr for bridge.ts to capture."""
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
# OCR quality tiers backed by PaddleOCR. PaddleOCR ships as the GPU build
# (paddlepaddle-gpu) in the amd64 bundle; its native libs dlopen libcuda.so.1 at
# import and segfault on a CPU-only host, so these tiers need a usable GPU.
PADDLE_QUALITY_TIERS = ("balanced", "best")
def effective_quality(requested):
"""Return the OCR quality tier that can actually run on this host.
On a CPU-only host the PaddleOCR tiers (balanced/best) cannot load, so they
transparently fall back to "fast" (Tesseract), which runs on CPU. This keeps a
GPU-less host from importing paddlepaddle-gpu, whose import segfaults and wedges
the shared AI dispatcher.
"""
if requested in PADDLE_QUALITY_TIERS and not gpu_available():
return "fast"
return requested
TESSERACT_LANG_MAP = {
"en": "eng", "de": "deu", "fr": "fra", "es": "spa",
"zh": "chi_sim", "ja": "jpn", "ko": "kor",
}
PADDLE_LANG_MAP = {
"en": "en", "de": "latin", "fr": "latin", "es": "latin",
"zh": "ch", "ja": "japan", "ko": "korean",
}
# Bundled PaddleOCR models (shipped by the OCR feature bundle into MODELS_PATH).
# Pinning the constructor at these dirs keeps OCR fully offline / air-gapped and
# skips slow HuggingFace model resolution on first use. PP-OCRv5 server rec
# covers Chinese+English; latin covers en/de/fr/es; plus a dedicated Korean rec.
PADDLE_DET_MODEL = "PP-OCRv5_server_det"
PADDLE_TEXTLINE_MODEL = "PP-LCNet_x1_0_textline_ori"
PADDLE_REC_MODEL = {
"ch": "PP-OCRv5_server_rec",
"en": "latin_PP-OCRv5_mobile_rec",
"latin": "latin_PP-OCRv5_mobile_rec",
"korean": "korean_PP-OCRv5_mobile_rec",
}
def _bundled_paddle_kwargs(paddle_lang):
"""Build PaddleOCR kwargs that use the bundled models in MODELS_PATH.
Only pins a component when its model is actually present on disk, so a
partial bundle (or an unbundled language such as Japanese) falls back to
PaddleOCR's default resolution for that component. The doc-orientation and
doc-unwarping models are not bundled and not needed for plain OCR, so they
are disabled to avoid a runtime HuggingFace download.
"""
models_dir = os.environ.get("MODELS_PATH", "/data/ai/models")
def model_dir(name):
if not name:
return None
path = os.path.join(models_dir, name)
return path if os.path.isdir(path) else None
kwargs = {"use_doc_orientation_classify": False, "use_doc_unwarping": False}
det = model_dir(PADDLE_DET_MODEL)
if det:
kwargs["text_detection_model_name"] = PADDLE_DET_MODEL
kwargs["text_detection_model_dir"] = det
rec_name = PADDLE_REC_MODEL.get(paddle_lang)
rec = model_dir(rec_name)
if rec:
kwargs["text_recognition_model_name"] = rec_name
kwargs["text_recognition_model_dir"] = rec
textline = model_dir(PADDLE_TEXTLINE_MODEL)
if textline:
kwargs["textline_orientation_model_name"] = PADDLE_TEXTLINE_MODEL
kwargs["textline_orientation_model_dir"] = textline
kwargs["use_textline_orientation"] = True
else:
kwargs["use_textline_orientation"] = False
return kwargs
def auto_detect_language(input_path):
"""Detect the predominant script in the image using Tesseract multi-lang.
Runs a quick Tesseract pass with all installed language packs,
then analyzes the Unicode character ranges in the output to
determine which PaddleOCR language model to use.
"""
import subprocess
try:
result = subprocess.run(
["tesseract", input_path, "stdout", "-l", "eng+kor+chi_sim+jpn"],
capture_output=True, text=True, timeout=30,
)
text = result.stdout.strip()
if not text:
return "en"
hangul = sum(1 for c in text if "\uAC00" <= c <= "\uD7AF" or "\u1100" <= c <= "\u11FF")
cjk = sum(1 for c in text if "\u4E00" <= c <= "\u9FFF")
hiragana = sum(1 for c in text if "\u3040" <= c <= "\u309F")
katakana = sum(1 for c in text if "\u30A0" <= c <= "\u30FF")
latin = sum(1 for c in text if c.isascii() and c.isalpha())
total = hangul + cjk + hiragana + katakana + latin
if total == 0:
return "en"
if hangul / total > 0.3:
return "ko"
if (hiragana + katakana) / total > 0.2:
return "ja"
if cjk / total > 0.3:
return "zh"
return "en"
except Exception:
return "en"
def run_tesseract(input_path, language, is_auto=False):
"""Run Tesseract OCR (Fast tier)."""
import subprocess
# When auto-detected, use all installed language packs for best coverage
if is_auto:
tess_lang = "eng+kor+chi_sim+jpn+deu+fra+spa"
else:
tess_lang = TESSERACT_LANG_MAP.get(language, "eng")
emit_progress(30, "Scanning")
result = subprocess.run(
["tesseract", input_path, "stdout", "-l", tess_lang],
capture_output=True,
text=True,
timeout=120,
)
emit_progress(70, "Extracting text")
text = result.stdout.strip()
if result.returncode != 0 and not text:
raise RuntimeError(result.stderr.strip() or "Tesseract failed")
return text
def _extract_ocr_texts(results):
"""Extract text from PaddleOCR 3.x result objects.
Handles multiple result formats across PaddleOCR versions:
- 3.4.x: OCRResult with .json["res"]["rec_texts"]
- Earlier: result objects with .res dict containing "text" list
"""
text_parts = []
for res in results:
# PaddleOCR 3.4.x format: OCRResult with .json dict
if hasattr(res, "json") and isinstance(res.json, dict):
inner = res.json.get("res", {})
rec_texts = inner.get("rec_texts", [])
if rec_texts:
text_parts.extend(rec_texts)
continue
# Older format: .res dict with "text" list
if hasattr(res, "res") and isinstance(res.res, dict):
text_parts.extend(res.res.get("text", []))
return "\n".join(text_parts)
def run_paddleocr_v5(input_path, language):
"""Run PaddleOCR PP-OCRv5 server models (Balanced tier)."""
# GPU-only: paddlepaddle-gpu segfaults at import on a CPU-only host (libcuda
# absent). Refuse before importing so the caller falls back to Tesseract.
if not gpu_available():
raise ImportError(
"PaddleOCR (paddlepaddle-gpu) requires a GPU; the amd64 bundle ships the "
"GPU build, which cannot load on a CPU-only host. Use quality=fast (Tesseract)."
)
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
stdout_fd = os.dup(1)
os.dup2(2, 1)
try:
import logging
from paddleocr import PaddleOCR
# Suppress PaddleOCR internal logging (replaces removed show_log param)
for name in ("ppocr", "paddleocr", "paddle"):
logging.getLogger(name).setLevel(logging.ERROR)
paddle_lang = PADDLE_LANG_MAP.get(language, "en")
device = "gpu:0" if gpu_available() else "cpu"
emit_progress(20, "Loading")
mk = _bundled_paddle_kwargs(paddle_lang)
# When a bundled recognizer is pinned, the model selects the script, so
# we omit lang (this is the proven fully-offline path). Lang-based
# resolution for a language without a bundled recognizer (e.g. ja) and
# a missing detection model both make PaddleOCR fetch models over the
# network, which strict offline mode blocks with a clear error.
if "text_recognition_model_dir" not in mk:
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"PaddleOCR recognition model for language '{language}'")
mk["lang"] = paddle_lang
if "text_detection_model_dir" not in mk:
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"PaddleOCR text detection model ({PADDLE_DET_MODEL})")
ocr = PaddleOCR(
device=device,
ocr_version="PP-OCRv5",
enable_mkldnn=False,
**mk,
)
emit_progress(30, "Scanning")
results = ocr.predict(input=input_path)
emit_progress(70, "Extracting text")
text = _extract_ocr_texts(results)
finally:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
return text
def run_paddleocr_vl(input_path):
"""Run PaddleOCR-VL 1.5 vision-language model (Best tier).
The VLM is lazy-loaded on first call and stays resident in the
dispatcher process for subsequent requests.
Requires PaddlePaddle >= 3.2 for fused_rms_norm_ext.
"""
global _paddleocr_vl_instance
# GPU-only: see run_paddleocr_v5. Refuse before importing paddle on CPU.
if not gpu_available():
raise ImportError(
"PaddleOCR-VL (paddlepaddle-gpu) requires a GPU; the amd64 bundle ships the "
"GPU build, which cannot load on a CPU-only host. Use quality=balanced or fast."
)
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
stdout_fd = os.dup(1)
os.dup2(2, 1)
try:
if _paddleocr_vl_instance is None:
emit_progress(15, "Loading model")
from paddleocr import PaddleOCRVL
device = "gpu" if gpu_available() else "cpu"
_paddleocr_vl_instance = PaddleOCRVL(device=device)
emit_progress(30, "Scanning")
output = _paddleocr_vl_instance.predict(input_path)
emit_progress(70, "Extracting text")
text_parts = []
for res in output:
# PaddleOCR-VL 1.5+: markdown_texts holds the extracted text
if hasattr(res, "markdown") and isinstance(res.markdown, dict):
md_text = res.markdown.get("markdown_texts", "")
if md_text:
text_parts.append(md_text)
continue
if hasattr(res, "parsing_res_list"):
for block in res.parsing_res_list:
content = block.get("block_content", "")
if content:
text_parts.append(content)
elif hasattr(res, "rec_text"):
text_parts.append(res.rec_text)
# Also try the json-based extraction as fallback
elif hasattr(res, "json") and isinstance(res.json, dict):
inner = res.json.get("res", {})
rec_texts = inner.get("rec_texts", [])
text_parts.extend(rec_texts)
text = "\n".join(text_parts)
finally:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
return text
def main():
input_path = sys.argv[1]
settings = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
quality = settings.get("quality", None)
language = settings.get("language", "auto")
enhance = settings.get("enhance", True)
# Backward compat: old "engine" param maps to quality
if quality is None:
engine = settings.get("engine", "tesseract")
quality = "fast" if engine == "tesseract" else "balanced"
# On a CPU-only host, downgrade GPU-only tiers (PaddleOCR) to Tesseract so we
# never import paddlepaddle-gpu, whose import segfaults and wedges the dispatcher.
downgraded = effective_quality(quality)
if downgraded != quality:
print(
json.dumps({"info": f"{quality} OCR needs a GPU; using {downgraded} (Tesseract) on CPU"}),
file=sys.stderr,
flush=True,
)
quality = downgraded
preprocessed_path = None
try:
emit_progress(5, "Preparing")
# Preprocessing (if enabled)
if enhance:
emit_progress(8, "Enhancing image")
try:
from ocr_preprocess import preprocess
preprocessed_path = input_path + "_enhanced.png"
preprocess(input_path, preprocessed_path)
input_path = preprocessed_path
except Exception as e:
print(json.dumps({"warning": f"Enhancement skipped: {e}"}), file=sys.stderr, flush=True)
preprocessed_path = None
# Language auto-detection
was_auto = language == "auto"
if was_auto:
emit_progress(10, "Detecting language")
language = auto_detect_language(input_path)
engine_used = quality
# Route to engine based on quality tier
if quality == "fast":
try:
text = run_tesseract(input_path, language, is_auto=was_auto)
engine_used = "tesseract"
except FileNotFoundError:
print(json.dumps({"success": False, "error": "Tesseract is not installed"}))
sys.exit(1)
elif quality == "balanced":
try:
text = run_paddleocr_v5(input_path, language)
engine_used = "paddleocr-v5"
except ImportError as e:
print(json.dumps({
"success": False,
"error": (
f"PaddleOCR is not installed: {e}. "
"Install the OCR feature or use quality=fast for Tesseract."
),
}))
sys.exit(1)
except Exception as e:
print(json.dumps({
"success": False,
"error": (
f"PaddleOCR PP-OCRv5 failed: {type(e).__name__}: {e}. "
"Install the OCR feature or use quality=fast for Tesseract."
),
}))
sys.exit(1)
elif quality == "best":
try:
text = run_paddleocr_vl(input_path)
engine_used = "paddleocr-vl"
except ImportError as e:
print(json.dumps({
"success": False,
"error": (
f"PaddleOCR-VL is not available: {e}. "
"Install the OCR feature or use quality=balanced for PP-OCRv5."
),
}))
sys.exit(1)
except Exception as e:
print(json.dumps({
"success": False,
"error": (
f"PaddleOCR-VL failed: {type(e).__name__}: {e}. "
"Install the OCR feature or use quality=balanced for PP-OCRv5."
),
}))
sys.exit(1)
else:
print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"}))
sys.exit(1)
emit_progress(95, "Done")
print(json.dumps({"success": True, "text": text, "engine": engine_used}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
finally:
# Clean up preprocessed temp file
if preprocessed_path:
try:
os.remove(preprocessed_path)
except OSError:
pass
if __name__ == "__main__":
main()
-196
View File
@@ -1,196 +0,0 @@
"""OCR text extraction from PDF documents.
Rasterizes each page via PyMuPDF (fitz) and runs the same OCR engine
pipeline as ocr.py (tesseract / PaddleOCR PP-OCRv5 / PaddleOCR-VL).
Contract: argv[1] = pdf path, argv[2] = options JSON {"quality","language","pages"}.
Output: {"success": true, "engine": ..., "pages": n, "text": ...} to stdout.
Errors: {"error": ...} to stdout, exit 1.
Engine functions are imported from sibling ocr.py (the dispatcher adds the
scripts directory to sys.path, and `import ocr` loads ocr.py as a module
without triggering its __main__ guard).
"""
import sys
import json
import os
import tempfile
MAX_PAGES = 50
def emit_progress(percent, stage):
"""Emit structured progress to stderr for bridge.ts to capture."""
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
def parse_page_spec(spec, total_pages):
"""Parse a page spec like 'all', '1-3,5', '2,4-6' into a sorted list of 0-based indices.
Returns (pages, error) where error is a string if the spec is invalid.
"""
if spec.strip().lower() == "all":
return list(range(total_pages)), None
pages = set()
parts = spec.split(",")
for part in parts:
part = part.strip()
if not part:
continue
if "-" in part:
bounds = part.split("-", 1)
try:
start = int(bounds[0].strip())
end = int(bounds[1].strip())
except ValueError:
return None, f"Invalid page range: {part}"
if start < 1 or end < 1:
return None, f"Invalid page range: {part} (pages start at 1)"
if start > end:
return None, f"Invalid page range: {part} (start > end)"
if end > total_pages:
return None, f"Invalid page range: {part} (document has {total_pages} pages)"
for i in range(start, end + 1):
pages.add(i - 1) # 0-based
else:
try:
num = int(part)
except ValueError:
return None, f"Invalid page number: {part}"
if num < 1:
return None, f"Invalid page number: {num} (pages start at 1)"
if num > total_pages:
return None, f"Invalid page number: {num} (document has {total_pages} pages)"
pages.add(num - 1) # 0-based
if not pages:
return None, "No pages specified"
return sorted(pages), None
def main():
input_path = sys.argv[1]
settings = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
quality = settings.get("quality", "balanced")
language = settings.get("language", "auto")
pages_spec = settings.get("pages", "all")
try:
emit_progress(2, "Opening PDF")
# Lazy import: fitz (PyMuPDF) is in the base venv but keep it lazy
# so py_compile succeeds without it.
import fitz
doc = fitz.open(input_path)
total_pages = len(doc)
if total_pages == 0:
print(json.dumps({"error": "PDF has no pages"}))
sys.exit(1)
# Parse page specification
page_indices, parse_error = parse_page_spec(pages_spec, total_pages)
if parse_error:
print(json.dumps({"error": parse_error}))
sys.exit(1)
if len(page_indices) > MAX_PAGES:
print(json.dumps({"error": f"Too many pages for OCR (max {MAX_PAGES})"}))
sys.exit(1)
emit_progress(5, "Preparing")
# Import engine functions from sibling ocr.py.
# The dispatcher's scripts directory is on sys.path, so this import
# loads ocr.py as a module (its __main__ guard prevents re-execution).
import ocr as ocr_module
# Language auto-detection on the first page's raster
was_auto = language == "auto"
# Create a temp directory for scratch PNGs next to the input
scratch_dir = tempfile.mkdtemp(
prefix="ocr_pdf_",
dir=os.path.dirname(input_path) or tempfile.gettempdir(),
)
page_texts = []
engine_used = quality
num_pages = len(page_indices)
for i, page_idx in enumerate(page_indices):
page_num = page_idx + 1 # 1-based for display
pct = 5 + int((i / num_pages) * 85)
emit_progress(pct, f"Page {page_num}/{total_pages}")
# Rasterize page to PNG at 200 DPI
page = doc[page_idx]
pix = page.get_pixmap(dpi=200)
png_path = os.path.join(scratch_dir, f"page_{page_idx}.png")
pix.save(png_path)
# Auto-detect language on the first page only
current_lang = language
if was_auto and i == 0:
current_lang = ocr_module.auto_detect_language(png_path)
language = current_lang # reuse for remaining pages
elif was_auto:
current_lang = language # already detected
# Always route PDF OCR through tesseract. PaddleOCR segfaults
# on arm64 CPU when processing rasterised PDF pages (SIGSEGV in
# the doc-orientation / structural-analysis stage). Tesseract is
# reliable for page-level images and is already installed in the
# container with multi-language packs. The image-OCR tool still
# offers PaddleOCR tiers for single images where it is stable.
if quality not in ("fast", "balanced", "best"):
print(json.dumps({"error": f"Unknown quality: {quality}"}))
sys.exit(1)
text = ocr_module.run_tesseract(png_path, current_lang, is_auto=was_auto)
engine_used = "tesseract"
page_texts.append((page_num, text))
# Clean up scratch PNG immediately
try:
os.remove(png_path)
except OSError:
pass
# Clean up scratch directory
try:
os.rmdir(scratch_dir)
except OSError:
pass
doc.close()
# Join page texts with page headers
parts = []
for page_num, text in page_texts:
parts.append(f"\n\n--- Page {page_num} ---\n\n{text}")
# Strip leading newlines from the first page
full_text = "".join(parts).strip()
emit_progress(95, "Done")
print(json.dumps({
"success": True,
"engine": engine_used,
"pages": num_pages,
"text": full_text,
}))
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,263 @@
"""Persistent JSON-line entrypoint for the isolated OCR runtime."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any, BinaryIO, Callable, Mapping, TextIO
try:
# Direct execution inside the immutable runtime generation.
from ocr_runtime import OcrRuntime
except ModuleNotFoundError: # pragma: no cover - exercised by package imports in tooling
from .ocr_runtime import OcrRuntime
PROTOCOL_VERSION = 1
MAX_REQUEST_BYTES = 64 * 1024
MAX_ERROR_CHARS = 400
def configure_offline_environment() -> None:
"""Set offline switches before importing any optional model libraries."""
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
os.environ["SNAPOTTER_OCR_OFFLINE"] = "1"
os.environ["NO_PROXY"] = "*"
os.environ["no_proxy"] = "*"
def _request_id(request: Any) -> str:
if isinstance(request, Mapping):
value = request.get("requestId")
if isinstance(value, str) and 0 < len(value) <= 128:
return value
return "unknown"
def _failure(request_id: str, code: str, message: str) -> dict[str, Any]:
bounded = " ".join(str(message).split())[:MAX_ERROR_CHARS] or "OCR runtime request failed"
return {
"protocolVersion": PROTOCOL_VERSION,
"requestId": request_id,
"ok": False,
"error": {"code": code, "message": bounded},
}
def _parse_settings(value: str) -> dict[str, Any]:
try:
settings = json.loads(value)
except json.JSONDecodeError as error:
raise ValueError("OCR settings are not valid JSON") from error
if not isinstance(settings, dict):
raise ValueError("OCR settings must be a JSON object")
return settings
def _parse_pages(value: str) -> tuple[tuple[int, Path], ...]:
try:
raw_pages = json.loads(value)
except json.JSONDecodeError as error:
raise ValueError("Rasterized pages are not valid JSON") from error
if not isinstance(raw_pages, list) or not raw_pages:
raise ValueError("Rasterized pages must be a non-empty JSON list")
pages = []
for index, item in enumerate(raw_pages):
if isinstance(item, str):
pages.append((index + 1, Path(item)))
continue
if (
isinstance(item, dict)
and isinstance(item.get("page"), int)
and not isinstance(item.get("page"), bool)
and isinstance(item.get("path"), str)
):
pages.append((item["page"], Path(item["path"])))
continue
raise ValueError("Each rasterized page must be a path or {page, path} object")
return tuple(pages)
def _validate_request(request: Any) -> tuple[str, str, list[str]]:
if not isinstance(request, dict):
raise ValueError("OCR runtime request must be an object")
request_id = _request_id(request)
if request_id == "unknown":
raise ValueError("OCR runtime requestId is missing or invalid")
protocol_version = request.get("protocolVersion")
if type(protocol_version) is not int or protocol_version != PROTOCOL_VERSION:
raise ValueError(f"Unsupported OCR runtime protocol version; expected {PROTOCOL_VERSION}")
script = request.get("script")
if script not in ("ocr", "ocr_pdf", "smoke"):
raise ValueError("OCR runtime script must be ocr, ocr_pdf, or smoke")
args = request.get("args")
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
raise ValueError("OCR runtime args must be a list of strings")
if script == "smoke" and args:
raise ValueError("OCR runtime smoke requires no arguments")
if script != "smoke" and len(args) != 2:
raise ValueError("OCR runtime scripts require exactly two arguments")
return request_id, script, args
def _prepare_request(request: Any) -> tuple[str, str, tuple[Any, ...]]:
"""Validate and decode a frame without constructing model sessions."""
request_id, script, args = _validate_request(request)
if script == "smoke":
return request_id, script, ()
settings = _parse_settings(args[1])
if script == "ocr":
return request_id, script, (Path(args[0]), settings)
return request_id, script, (_parse_pages(args[0]), settings)
def _process_prepared_request(
prepared: tuple[str, str, tuple[Any, ...]], runtime: OcrRuntime
) -> dict[str, Any]:
request_id, script, arguments = prepared
try:
if script == "smoke":
result = runtime.smoke()
elif script == "ocr":
result = runtime.recognize_image(*arguments)
else:
result = runtime.recognize_pages(*arguments)
return {
"protocolVersion": PROTOCOL_VERSION,
"requestId": request_id,
"ok": True,
"result": result,
}
except ValueError as error:
return _failure(request_id, "invalid-request", str(error))
except FileNotFoundError as error:
return _failure(request_id, "file-not-found", str(error))
except Exception as error:
return _failure(request_id, "ocr-runtime-failed", str(error))
def process_request(
request: Any,
*,
runtime: OcrRuntime | None = None,
runtime_factory: Callable[[], OcrRuntime] = OcrRuntime,
) -> dict[str, Any]:
request_id = _request_id(request)
try:
prepared = _prepare_request(request)
request_id = prepared[0]
active_runtime = runtime or runtime_factory()
return _process_prepared_request(prepared, active_runtime)
except ValueError as error:
return _failure(request_id, "invalid-request", str(error))
except FileNotFoundError as error:
return _failure(request_id, "file-not-found", str(error))
except Exception as error:
return _failure(request_id, "ocr-runtime-failed", str(error))
def main(
argv: list[str] | None = None,
*,
runtime_factory: Callable[[], OcrRuntime] = OcrRuntime,
stdin: BinaryIO | None = None,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
) -> int:
configure_offline_environment()
arguments = list(sys.argv[1:] if argv is None else argv)
input_stream = sys.stdin.buffer if stdin is None else stdin
output_stream = sys.stdout if stdout is None else stdout
error_stream = sys.stderr if stderr is None else stderr
if arguments == ["--smoke"]:
try:
smoke_result = runtime_factory().smoke()
except Exception as error:
error_stream.write(f"OCR runtime smoke failed: {error}\n")
error_stream.flush()
return 1
output_stream.write(
json.dumps(
{"smoke": True, **smoke_result},
ensure_ascii=False,
separators=(",", ":"),
)
+ "\n"
)
output_stream.flush()
return 0
if arguments:
error_stream.write("Usage: ocr_runner.py [--smoke]\n")
error_stream.flush()
return 2
runtime: OcrRuntime | None = None
handled_requests = 0
while True:
raw = input_stream.readline(MAX_REQUEST_BYTES + 1)
if not raw:
if handled_requests == 0:
response = _failure(
"unknown", "invalid-request", "OCR runtime request is empty"
)
output_stream.write(
json.dumps(response, ensure_ascii=False, separators=(",", ":"))
+ "\n"
)
output_stream.flush()
return 1
return 0
exit_after_response = False
if len(raw) > MAX_REQUEST_BYTES:
response = _failure(
"unknown", "invalid-request", "OCR runtime request is too large"
)
# readline() leaves the rest of an oversized unterminated frame in
# the stream. Exit after the bounded error instead of interpreting
# the tail as another request and losing frame synchronization.
exit_after_response = not raw.endswith(b"\n")
else:
try:
request = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
response = _failure(
"unknown", "invalid-request", f"Malformed JSON request: {error}"
)
else:
try:
prepared = _prepare_request(request)
except ValueError as error:
response = _failure(
_request_id(request), "invalid-request", str(error)
)
else:
if runtime is None:
try:
runtime = runtime_factory()
except Exception as error:
response = _failure(
_request_id(request), "ocr-runtime-failed", str(error)
)
exit_after_response = True
else:
response = _process_prepared_request(prepared, runtime)
else:
response = _process_prepared_request(prepared, runtime)
output_stream.write(
json.dumps(response, ensure_ascii=False, separators=(",", ":")) + "\n"
)
output_stream.flush()
handled_requests += 1
if exit_after_response:
return 1
if __name__ == "__main__":
raise SystemExit(main())
-2
View File
@@ -7,8 +7,6 @@
# allowlisted model names (remove_bg.py ALLOWED_MODELS), never user paths.
rembg==2.0.69
realesrgan==0.3.0
paddleocr==2.9.1
paddlepaddle-gpu==3.0.0
mediapipe>=0.10.21
onnxruntime-gpu==1.20.1
numpy==1.26.4
-2
View File
@@ -7,8 +7,6 @@
# allowlisted model names (remove_bg.py ALLOWED_MODELS), never user paths.
rembg[cpu]==2.0.69
realesrgan==0.3.0
paddleocr[doc-parser]>=3.4.0,<3.5.0
paddlepaddle>=3.0.0,<3.1.0
mediapipe>=0.10.21
onnxruntime==1.20.1
numpy==1.26.4
+6 -62
View File
@@ -1,15 +1,5 @@
"""gpu_available() must recognize a GPU that only paddle can use.
The OCR feature bundle ships paddlepaddle-gpu but no torch or ONNX Runtime. On an
OCR-only GPU host the torch and ONNX probes both come up empty, so before this
fix gpu_available() fell through to a plain nvidia-smi check that returned False
by design, and OCR silently downgraded to Tesseract. gpu_available() now probes
paddle too, in an isolated subprocess, but only after nvidia-smi confirms a GPU
is physically present (importing paddlepaddle-gpu in-process segfaults on a
GPU-less host and would wedge the shared AI dispatcher).
"""
"""Framework-specific CUDA probes must never depend on obsolete Paddle OCR."""
import os
import subprocess
import sys
import types
@@ -17,32 +7,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import gpu # noqa: E402
# --- The isolated paddle probe --------------------------------------------
def test_paddle_probe_true_when_subprocess_reports_cuda(monkeypatch):
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, returncode=0, stdout="", stderr="")
monkeypatch.setattr(gpu.subprocess, "run", fake_run)
assert gpu._try_paddle_cuda_subprocess() is True
def test_paddle_probe_false_when_subprocess_reports_no_cuda(monkeypatch):
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, returncode=1, stdout="", stderr="")
monkeypatch.setattr(gpu.subprocess, "run", fake_run)
assert gpu._try_paddle_cuda_subprocess() is False
def test_paddle_probe_false_on_timeout(monkeypatch):
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, 30)
monkeypatch.setattr(gpu.subprocess, "run", fake_run)
assert gpu._try_paddle_cuda_subprocess() is False
# --- gpu_available() orchestration -----------------------------------------
def _patch_probes(monkeypatch, torch, onnx, smi):
@@ -54,39 +18,19 @@ def _patch_probes(monkeypatch, torch, onnx, smi):
gpu.gpu_available.cache_clear()
def test_gpu_available_true_when_only_paddle_sees_gpu(monkeypatch):
# OCR-only GPU box: torch and ONNX absent, GPU present, paddle can use it.
def test_gpu_available_does_not_treat_hardware_presence_as_framework_support(monkeypatch):
_patch_probes(monkeypatch, torch=False, onnx=False, smi="NVIDIA GeForce RTX 4070")
monkeypatch.setattr(gpu, "_try_paddle_cuda_subprocess", lambda: True)
assert gpu.gpu_available() is True
def test_gpu_available_false_when_paddle_cannot_use_gpu(monkeypatch):
# GPU present but paddle is a CPU build or cannot init CUDA: stay conservative.
_patch_probes(monkeypatch, torch=False, onnx=False, smi="NVIDIA GeForce RTX 4070")
monkeypatch.setattr(gpu, "_try_paddle_cuda_subprocess", lambda: False)
assert gpu.gpu_available() is False
def test_gpu_available_never_probes_paddle_without_a_gpu(monkeypatch):
# Safety invariant: on a GPU-less host a paddlepaddle-gpu import segfaults, so
# the probe must never run when nvidia-smi finds no GPU.
_patch_probes(monkeypatch, torch=False, onnx=False, smi=None)
called = {"paddle": False}
def spy():
called["paddle"] = True
return True
monkeypatch.setattr(gpu, "_try_paddle_cuda_subprocess", spy)
assert gpu.gpu_available() is False
assert called["paddle"] is False
def test_gpu_module_has_no_paddle_probe():
assert not hasattr(gpu, "_try_paddle_cuda_subprocess")
# --- Per-framework detection (torch, ctranslate2) --------------------------
#
# Torch and CTranslate2 tools must gate on their OWN framework, not the general
# gpu_available(), which can report True based on paddle or ONNX Runtime while
# gpu_available(), which can report True based on ONNX Runtime while
# torch is a CPU-only build. Consuming the shared boolean would make those tools
# route to CUDA on a device their framework cannot use.
@@ -103,7 +47,7 @@ def test_torch_gpu_available_false_when_override_disables_gpu(monkeypatch):
def test_torch_gpu_available_false_when_torch_is_cpu_only(monkeypatch):
# The crux: gpu_available() may be True via paddle or ONNX on a GPU box, but a
# The crux: gpu_available() may be True via ONNX on a GPU box, but a
# CPU-only torch build must report no GPU so torch tools do not touch CUDA.
monkeypatch.delenv("SNAPOTTER_GPU", raising=False)
monkeypatch.setattr(gpu, "_try_torch_cuda", lambda: False)
File diff suppressed because it is too large Load Diff
@@ -1,44 +0,0 @@
"""OCR must degrade gracefully on CPU-only hosts.
The amd64 AI bundle ships paddlepaddle-gpu, whose native libraries dlopen
libcuda.so.1 at import time and segfault on a host without a GPU (libcuda is the
driver lib, injected only by nvidia-container-toolkit on GPU hosts). That segfault
crashes the shared long-lived AI dispatcher and wedges all AI. So on a CPU-only
host the PaddleOCR tiers (balanced/best) must transparently fall back to Tesseract
and must never reach the paddle import.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import ocr # noqa: E402
def test_effective_quality_downgrades_paddle_tiers_on_cpu(monkeypatch):
monkeypatch.setattr(ocr, "gpu_available", lambda: False)
assert ocr.effective_quality("balanced") == "fast"
assert ocr.effective_quality("best") == "fast"
assert ocr.effective_quality("fast") == "fast"
def test_effective_quality_preserves_paddle_tiers_on_gpu(monkeypatch):
monkeypatch.setattr(ocr, "gpu_available", lambda: True)
assert ocr.effective_quality("balanced") == "balanced"
assert ocr.effective_quality("best") == "best"
assert ocr.effective_quality("fast") == "fast"
def test_run_paddleocr_v5_refuses_on_cpu_before_import(monkeypatch):
# Must raise a GPU-specific error (the guard), NOT attempt the paddle import
# that would segfault on a CPU-only host.
monkeypatch.setattr(ocr, "gpu_available", lambda: False)
with pytest.raises(ImportError, match="GPU"):
ocr.run_paddleocr_v5("/nonexistent.png", "en")
def test_run_paddleocr_vl_refuses_on_cpu_before_import(monkeypatch):
monkeypatch.setattr(ocr, "gpu_available", lambda: False)
with pytest.raises(ImportError, match="GPU"):
ocr.run_paddleocr_vl("/nonexistent.png")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
"""Accurate OCR must never re-enter the mutable legacy Python dispatcher."""
from pathlib import Path
from packages.ai.python import dispatcher
def test_legacy_dispatcher_does_not_allow_ocr_scripts():
assert "ocr" not in dispatcher.ALLOWED_SCRIPTS
assert "ocr_pdf" not in dispatcher.ALLOWED_SCRIPTS
def test_shared_requirements_do_not_install_paddle():
python_root = Path(__file__).resolve().parents[1]
for name in ("requirements.txt", "requirements-gpu.txt"):
requirements = (python_root / name).read_text(encoding="utf-8").lower()
assert "paddleocr" not in requirements
assert "paddlepaddle" not in requirements