mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* 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
264 lines
9.4 KiB
Python
264 lines
9.4 KiB
Python
"""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())
|