scan, split, OCR, prepare for LLM
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

surya_engine.py 4.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. from __future__ import annotations
  2. import functools
  3. import logging
  4. import os
  5. import shutil
  6. from pathlib import Path
  7. from typing import TYPE_CHECKING
  8. from src.env import load_env
  9. from src.image_utils import calc_scale_dims
  10. from src.models import OcrPageResult, ParsedBlock
  11. if TYPE_CHECKING:
  12. from PIL import Image as PILImage
  13. logger = logging.getLogger(__name__)
  14. # CPU optimizations
  15. _SURYA_CTX_PER_SLOT: int = 8192
  16. _SURYA_PARALLEL: int = 1
  17. _IMAGE_MAX_WIDTH: int = 1056
  18. def _setup_surya_env() -> None:
  19. load_env()
  20. os.environ.setdefault("SURYA_INFERENCE_BACKEND", "llamacpp")
  21. os.environ.setdefault("SURYA_INFERENCE_KEEP_ALIVE", "true")
  22. os.environ.setdefault("SURYA_GUIDED_LAYOUT", "false")
  23. os.environ.setdefault("SURYA_INFERENCE_PARALLEL", str(_SURYA_PARALLEL))
  24. os.environ.setdefault("SURYA_INFERENCE_CTX_PER_SLOT", str(_SURYA_CTX_PER_SLOT))
  25. if "LLAMA_CPP_BINARY" not in os.environ:
  26. binary = shutil.which("llama-server") or os.path.expanduser(
  27. "~/.local/bin/llama-server"
  28. )
  29. if Path(binary).exists():
  30. os.environ["LLAMA_CPP_BINARY"] = binary
  31. cpu_count = os.cpu_count() or 4
  32. os.environ.setdefault(
  33. "LLAMA_CPP_EXTRA_ARGS", f"-t {cpu_count} --threads-batch {cpu_count}"
  34. )
  35. class SuryaEngine:
  36. """OCR engine using Surya 2 VLM via llama.cpp.
  37. Optimizations for CPU:
  38. - llama-server kept alive between calls (SURYA_INFERENCE_KEEP_ALIVE=true)
  39. - reduced context per slot (8192 vs 12288) saves memory
  40. - single parallel slot (no batch needed for single page)
  41. - image pre-scaled to 1056px wide (≈96 DPI)
  42. - explicit thread count matching CPU cores
  43. """
  44. def __init__(self) -> None:
  45. _setup_surya_env()
  46. try:
  47. from surya.inference import SuryaInferenceManager
  48. from surya.recognition import RecognitionPredictor
  49. self._manager = SuryaInferenceManager()
  50. self._predictor = RecognitionPredictor(self._manager)
  51. except ImportError as e:
  52. msg = (
  53. "Surya engine not available. Install: pip install surya-ocr torch"
  54. )
  55. raise ImportError(msg) from e
  56. def process(self, image_path: str) -> OcrPageResult:
  57. from PIL import Image
  58. image = Image.open(image_path)
  59. return self._process_image(image)
  60. def _health_check(self) -> bool:
  61. import requests
  62. port = os.environ.get("SURYA_INFERENCE_PORT", "")
  63. host = os.environ.get("SURYA_INFERENCE_HOST", "127.0.0.1")
  64. if not port:
  65. return True
  66. try:
  67. r = requests.get(f"http://{host}:{port}/health", timeout=10)
  68. return r.status_code == 200
  69. except requests.RequestException:
  70. logger.warning("llama-server health check failed")
  71. return False
  72. def _process_image(self, image: PILImage.Image) -> OcrPageResult:
  73. scaled = _pre_scale(image)
  74. results = self._predictor([scaled])
  75. if results:
  76. return self._to_result(results[0], scaled)
  77. return OcrPageResult()
  78. @staticmethod
  79. def _to_result(page, image: PILImage.Image) -> OcrPageResult:
  80. blocks: list[ParsedBlock] = []
  81. for blk in getattr(page, "blocks", []):
  82. html = getattr(blk, "html", "") or ""
  83. label = getattr(blk, "label", "text")
  84. bbox = getattr(blk, "bbox", [0, 0, 0, 0])
  85. confidence = float(getattr(blk, "confidence", 0.9))
  86. blocks.append(
  87. ParsedBlock(
  88. label=label,
  89. content=html,
  90. bbox=(int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])),
  91. confidence=confidence,
  92. )
  93. )
  94. return OcrPageResult(
  95. blocks=blocks,
  96. raw_json={},
  97. width=image.width,
  98. height=image.height,
  99. )
  100. def _pre_scale(image: PILImage.Image) -> PILImage.Image:
  101. new_w, new_h = calc_scale_dims(*image.size, _IMAGE_MAX_WIDTH)
  102. if (new_w, new_h) != image.size:
  103. return image.resize((new_w, new_h), 1) # PIL.Image.LANCZOS
  104. return image
  105. @functools.lru_cache(maxsize=1)
  106. def _get_surya_engine() -> SuryaEngine:
  107. return SuryaEngine()
  108. def surya_ocr_image(image_path: str) -> OcrPageResult:
  109. engine = _get_surya_engine()
  110. if not engine._health_check():
  111. logger.warning("llama-server не отвечает, перезапуск...")
  112. _get_surya_engine.cache_clear()
  113. engine = _get_surya_engine()
  114. if not engine._health_check():
  115. raise RuntimeError(
  116. "llama-server недоступен после перезапуска. "
  117. "Проверьте: `ps aux | grep llama-server`. "
  118. "Попробуйте: `llama-server --version`"
  119. )
  120. return engine.process(image_path)