occular-ocr 0.5.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _occular_vcrt.py +47 -0
- occular/__init__.py +349 -0
- occular/__main__.py +6 -0
- occular/_beam.py +195 -0
- occular/_envcheck.py +93 -0
- occular/_langid.py +50 -0
- occular/_pylm.py +115 -0
- occular/_runtime.py +98 -0
- occular/_vcrt/NOTICE.txt +8 -0
- occular/_vcrt/_occular_vcrt.py +47 -0
- occular/_vcrt/msvcp140.dll +0 -0
- occular/_vcrt/msvcp140_1.dll +0 -0
- occular/_vcrt/msvcp140_atomic_wait.dll +0 -0
- occular/_vcrt/vcruntime140.dll +0 -0
- occular/_vcrt/vcruntime140_1.dll +0 -0
- occular/_winrt.py +15 -0
- occular/cli.py +326 -0
- occular/code_lock.py +96 -0
- occular/cyr_decode_config.json +82 -0
- occular/dbnet_detector_onnx.py +146 -0
- occular/decoder_lm.py +182 -0
- occular/deskew.py +60 -0
- occular/layout.py +119 -0
- occular/layout_order.py +120 -0
- occular/layout_order_model.py +149 -0
- occular/model_files.py +228 -0
- occular/multilingual.py +169 -0
- occular/orientation.py +94 -0
- occular/orientation_geom.py +264 -0
- occular/orientation_resolve.py +182 -0
- occular/parallel.py +247 -0
- occular/pipeline.py +589 -0
- occular/reading_order.py +79 -0
- occular/recognizer_onnx.py +189 -0
- occular/registry.py +62 -0
- occular/settings.py +89 -0
- occular/tables.py +530 -0
- occular/unwarp_uvdoc.py +66 -0
- occular-vcrt.pth +1 -0
- occular_ocr-0.5.0.dist-info/METADATA +1009 -0
- occular_ocr-0.5.0.dist-info/RECORD +46 -0
- occular_ocr-0.5.0.dist-info/WHEEL +5 -0
- occular_ocr-0.5.0.dist-info/entry_points.txt +2 -0
- occular_ocr-0.5.0.dist-info/licenses/LICENSE +202 -0
- occular_ocr-0.5.0.dist-info/licenses/LICENSE-pyctcdecode +205 -0
- occular_ocr-0.5.0.dist-info/top_level.txt +1 -0
_occular_vcrt.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""C++-рантайм Microsoft для onnxruntime/pyclipper/pymupdf на Windows x64 без Visual C++ Redistributable.
|
|
2
|
+
|
|
3
|
+
Колесо occular-ocr для win_amd64 кладёт этот модуль и occular-vcrt.pth в site-packages; .pth выполняется
|
|
4
|
+
при старте Python, поэтому рантайм на месте, даже если onnxruntime или fitz импортируют раньше occular.
|
|
5
|
+
Если все 5 файлов есть в System32 (Redistributable стоит — так почти везде), ничего не делаем. Иначе
|
|
6
|
+
грузим недостающие: сначала копию, которую находит Windows (например, рядом с python.exe), и только
|
|
7
|
+
если её нет — нашу из occular/_vcrt. Ошибки глотаем: .pth не должен мешать запуску Python.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
DLLS = ("vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll", "msvcp140_1.dll",
|
|
13
|
+
"msvcp140_atomic_wait.dll") # порядок важен: msvcp140 зависит от vcruntime140
|
|
14
|
+
loaded_from_package = []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def preload():
|
|
18
|
+
if sys.platform != "win32":
|
|
19
|
+
return
|
|
20
|
+
sys32 = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32")
|
|
21
|
+
if all(os.path.exists(os.path.join(sys32, n)) for n in DLLS):
|
|
22
|
+
return
|
|
23
|
+
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "occular", "_vcrt")
|
|
24
|
+
if not os.path.isdir(d):
|
|
25
|
+
return
|
|
26
|
+
import ctypes
|
|
27
|
+
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
28
|
+
k32.GetModuleHandleW.restype = ctypes.c_void_p
|
|
29
|
+
for name in DLLS:
|
|
30
|
+
if name in loaded_from_package or k32.GetModuleHandleW(name):
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
ctypes.WinDLL(name)
|
|
34
|
+
continue
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
ctypes.WinDLL(os.path.join(d, name))
|
|
39
|
+
loaded_from_package.append(name)
|
|
40
|
+
except OSError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
preload()
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
occular/__init__.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Occular OCR Package
|
|
3
|
+
|
|
4
|
+
# Для чайников (4 строки):
|
|
5
|
+
from occular import ocr
|
|
6
|
+
text = ocr("photo.png")
|
|
7
|
+
print(text)
|
|
8
|
+
|
|
9
|
+
# Для продвинутых:
|
|
10
|
+
from occular import OCRPipeline
|
|
11
|
+
pipeline = OCRPipeline()
|
|
12
|
+
results = pipeline.process_pdf("doc.pdf", dpi=300, workers=8)
|
|
13
|
+
|
|
14
|
+
# CLI:
|
|
15
|
+
ocr photo.png
|
|
16
|
+
ocr document.pdf --workers 4
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.5.0"
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from typing import Union, List, Dict, Optional
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import _envcheck
|
|
26
|
+
_envcheck.check() # конфликтующие сборки onnxruntime / opencv в одном модуле — предупредить сразу
|
|
27
|
+
|
|
28
|
+
from .registry import Registry
|
|
29
|
+
from .pipeline import OCRPipeline as _OCRPipelineBase, get_optimal_workers
|
|
30
|
+
from .model_files import (model_info, download_reading_order, reading_order_ready, WEIGHTS_DIR,
|
|
31
|
+
download_layout, layout_ready)
|
|
32
|
+
from .layout import LayoutDetector, LayoutRegion, LayoutResult, LAYOUT_LABELS
|
|
33
|
+
from .settings import Settings
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ============================================================================
|
|
37
|
+
# Регистрация компонентов
|
|
38
|
+
# ============================================================================
|
|
39
|
+
|
|
40
|
+
# Все модели — ONNX (CPU или CUDA через onnxruntime).
|
|
41
|
+
from .dbnet_detector_onnx import DBNetDetectorONNX
|
|
42
|
+
from .recognizer_onnx import CRNNRecognizerONNX
|
|
43
|
+
from .multilingual import MultilingualRecognizer, ALL_LANGS, CYR12_LANGS
|
|
44
|
+
Registry.register_detector("dbnet-onnx", DBNetDetectorONNX)
|
|
45
|
+
Registry.register_recognizer("crnn-onnx", CRNNRecognizerONNX)
|
|
46
|
+
# Мультиязычный роутер (русская ru/en + 12 кир-языков), выбирается через ocr(..., languages=[...]).
|
|
47
|
+
Registry.register_recognizer("multilingual", MultilingualRecognizer)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def TableRecognizer(*args, **kwargs):
|
|
51
|
+
"""Распознавание таблиц (детекция + структура + объединённые ячейки). Ленивая обёртка —
|
|
52
|
+
модели грузятся только при вызове. См. occular.tables.TableRecognizer."""
|
|
53
|
+
from .tables import TableRecognizer as _TR
|
|
54
|
+
return _TR(*args, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_registered():
|
|
58
|
+
"""Для совместимости — компоненты уже зарегистрированы при импорте"""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ============================================================================
|
|
63
|
+
# Простой API для чайников
|
|
64
|
+
# ============================================================================
|
|
65
|
+
|
|
66
|
+
def _normalize_languages(languages, arch=None):
|
|
67
|
+
"""None/[] → русская модель по умолчанию (быстрый путь, обратная совместимость).
|
|
68
|
+
Непустой список или 'auto' → мультиязычный роутер; вернуть (recognizer_name, recognizer_kwargs).
|
|
69
|
+
arch — архитектура распознавателя ('svtr_lcnet' по умолчанию, 'svtr_t' — крупная)."""
|
|
70
|
+
extra = {"arch": arch} if arch else {}
|
|
71
|
+
if not languages:
|
|
72
|
+
return "crnn-onnx", (extra or None)
|
|
73
|
+
if isinstance(languages, str):
|
|
74
|
+
if languages.lower() == "auto":
|
|
75
|
+
return "multilingual", {"languages": None, **extra} # авто-роутинг среди всех 14 языков
|
|
76
|
+
languages = [languages]
|
|
77
|
+
return "multilingual", {"languages": list(languages), **extra}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_pipeline(deskew, lm, num_threads, gpu, reading_order=False, languages=None,
|
|
81
|
+
orientation=False, arch=None, tables=False):
|
|
82
|
+
"""Собрать конвейер. languages=None → русский распознаватель (ru/en) по умолчанию;
|
|
83
|
+
список языков или 'auto' → мультиязычный роутер (русская + 12 кир-языков)."""
|
|
84
|
+
_ensure_registered()
|
|
85
|
+
rec_name, rec_kwargs = _normalize_languages(languages, arch)
|
|
86
|
+
return _OCRPipelineBase(detector_name="dbnet-onnx", recognizer_name=rec_name,
|
|
87
|
+
recognizer_kwargs=rec_kwargs,
|
|
88
|
+
deskew=deskew, reading_order=reading_order, lm=lm,
|
|
89
|
+
num_threads=num_threads, gpu=gpu, orientation=orientation, tables=tables)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ocr(file_path: str, *, deskew: bool = True, lm: bool = True,
|
|
93
|
+
num_threads: Optional[int] = None, gpu: bool = False,
|
|
94
|
+
languages=None, orientation: bool = False, recognizer: Optional[str] = None,
|
|
95
|
+
tables: bool = False) -> Union[str, List[str]]:
|
|
96
|
+
"""
|
|
97
|
+
Распознать текст из изображения или PDF.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
file_path: путь к файлу (изображение или PDF)
|
|
101
|
+
deskew: выпрямлять наклон скана (по умолчанию True)
|
|
102
|
+
lm: beam-CTC + языковая модель для декодирования (по умолчанию True; False = быстрый greedy)
|
|
103
|
+
num_threads: потоков на страницу (None = min(доступные, 8))
|
|
104
|
+
gpu: исполнять на GPU/CUDA (нужен пакет onnxruntime-gpu; иначе CPU)
|
|
105
|
+
orientation: определять поворот страницы (0/90/180/270°) и выпрямлять (по умолчанию False)
|
|
106
|
+
recognizer: архитектура распознавателя — 'svtr_lcnet' (по умолчанию) или 'svtr_t' (крупная)
|
|
107
|
+
tables: находить таблицы; в тексте они выводятся Markdown-таблицами на своём месте (по умолчанию False)
|
|
108
|
+
languages: язык(и) текста. None (по умолчанию) — русская модель (ru/en). Список кодов —
|
|
109
|
+
мультиязычный роутер: один кир-язык (напр. ['uk']) читается им, несколько (напр.
|
|
110
|
+
['ru','kk','uk']) — построчным определением языка. 'auto' — авто среди всех 14.
|
|
111
|
+
Кир-языки: ba be bg cv kk ky mk mn sr tg tt uk.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Для изображения: строка с распознанным текстом
|
|
115
|
+
Для PDF: список строк (по одной на страницу)
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
>>> from occular import ocr
|
|
119
|
+
>>> text = ocr("photo.png") # русский/английский
|
|
120
|
+
>>> text = ocr("doc_uk.png", languages=["uk"]) # украинский
|
|
121
|
+
>>> print(text)
|
|
122
|
+
Привет мир
|
|
123
|
+
"""
|
|
124
|
+
from .tables import compose_text
|
|
125
|
+
pipeline = _build_pipeline(deskew, lm, num_threads, gpu, languages=languages,
|
|
126
|
+
orientation=orientation, arch=recognizer, tables=tables)
|
|
127
|
+
|
|
128
|
+
path = Path(file_path)
|
|
129
|
+
if path.suffix.lower() == '.pdf':
|
|
130
|
+
results = pipeline.process_pdf(file_path)
|
|
131
|
+
# Текст по страницам, строки сверху вниз (таблицы — Markdown на месте первой строки)
|
|
132
|
+
return [compose_text(sorted(page["results"], key=lambda r: r["quad"][0][1]), page.get("tables"))
|
|
133
|
+
for page in results]
|
|
134
|
+
else:
|
|
135
|
+
results = pipeline.process_image(file_path)
|
|
136
|
+
return compose_text(sorted(results, key=lambda r: r["quad"][0][1]), getattr(results, "tables", None))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ocr_detailed(file_path: str, *, deskew: bool = True, lm: bool = True,
|
|
140
|
+
num_threads: Optional[int] = None, gpu: bool = False,
|
|
141
|
+
languages=None, orientation: bool = False, recognizer: Optional[str] = None,
|
|
142
|
+
tables: bool = False) -> List[Dict]:
|
|
143
|
+
"""
|
|
144
|
+
Распознать текст с полной информацией (координаты, confidence).
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
file_path: путь к файлу
|
|
148
|
+
deskew: выпрямлять наклон скана (по умолчанию True)
|
|
149
|
+
lm: beam-CTC + языковая модель для декодирования (по умолчанию True; False = быстрый greedy)
|
|
150
|
+
num_threads: потоков на страницу (None = min(доступные, 8))
|
|
151
|
+
gpu: исполнять на GPU/CUDA (нужен пакет onnxruntime-gpu; иначе CPU)
|
|
152
|
+
orientation: определять поворот страницы (0/90/180/270°) и выпрямлять (по умолчанию False)
|
|
153
|
+
recognizer: архитектура распознавателя — 'svtr_lcnet' (по умолчанию) или 'svtr_t' (крупная)
|
|
154
|
+
languages: язык(и) текста (см. ocr()). None — русская модель (ru/en); список кодов или
|
|
155
|
+
'auto' — мультиязычный роутер (русская + 12 кир-языков).
|
|
156
|
+
tables: находить таблицы (по умолчанию False): строкам в таблице добавляются ключи 'table' и
|
|
157
|
+
'cell', структура таблиц — в атрибуте .tables результата (для PDF — ключ 'tables' страницы)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Список словарей {"quad": [...], "text": str, "confidence": float}
|
|
161
|
+
Для PDF: список страниц с результатами
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
>>> results = ocr_detailed("photo.png")
|
|
165
|
+
>>> for r in results:
|
|
166
|
+
... print(f"{r['text']} ({r['confidence']:.2f})")
|
|
167
|
+
"""
|
|
168
|
+
pipeline = _build_pipeline(deskew, lm, num_threads, gpu, languages=languages,
|
|
169
|
+
orientation=orientation, arch=recognizer, tables=tables)
|
|
170
|
+
|
|
171
|
+
path = Path(file_path)
|
|
172
|
+
if path.suffix.lower() == '.pdf':
|
|
173
|
+
return pipeline.process_pdf(file_path)
|
|
174
|
+
else:
|
|
175
|
+
return pipeline.process_image(file_path)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ============================================================================
|
|
179
|
+
# Продвинутый API
|
|
180
|
+
# ============================================================================
|
|
181
|
+
|
|
182
|
+
class OCRPipeline:
|
|
183
|
+
"""
|
|
184
|
+
OCR Pipeline для продвинутых пользователей.
|
|
185
|
+
|
|
186
|
+
Настройки — через объект Settings или отдельные kwargs (deskew, reading_order, num_threads).
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
>>> pipeline = OCRPipeline()
|
|
190
|
+
>>> results = pipeline.process_image("photo.png")
|
|
191
|
+
>>> text = pipeline.get_text("photo.png")
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
settings: Optional[Settings] = None,
|
|
197
|
+
*,
|
|
198
|
+
deskew: bool = True,
|
|
199
|
+
reading_order: bool = False,
|
|
200
|
+
lm: bool = True,
|
|
201
|
+
num_threads: Optional[int] = None,
|
|
202
|
+
gpu: bool = False,
|
|
203
|
+
detector: Optional[str] = None,
|
|
204
|
+
recognizer: Optional[str] = None,
|
|
205
|
+
languages=None,
|
|
206
|
+
orientation: bool = False,
|
|
207
|
+
unwarp=None,
|
|
208
|
+
unwarp_gate=True,
|
|
209
|
+
unwarp_margin: float = 1.05,
|
|
210
|
+
experimental_layout_order: bool = False,
|
|
211
|
+
layout_order_method: str = "model",
|
|
212
|
+
tables: bool = False
|
|
213
|
+
):
|
|
214
|
+
_ensure_registered()
|
|
215
|
+
|
|
216
|
+
# Единые настройки: можно передать объект Settings, либо отдельные kwargs (совместимость).
|
|
217
|
+
if settings is None:
|
|
218
|
+
settings = Settings(deskew=deskew, reading_order=reading_order, lm=lm,
|
|
219
|
+
num_threads=num_threads, gpu=gpu, orientation=orientation,
|
|
220
|
+
detector=detector, recognizer=recognizer, languages=languages,
|
|
221
|
+
unwarp=unwarp, unwarp_gate=unwarp_gate, unwarp_margin=unwarp_margin,
|
|
222
|
+
experimental_layout_order=experimental_layout_order,
|
|
223
|
+
layout_order_method=layout_order_method, tables=tables)
|
|
224
|
+
self.settings = settings
|
|
225
|
+
s = settings
|
|
226
|
+
|
|
227
|
+
# Порядок чтения (layout) — опционально, ВЫКЛ по умолчанию. Модель качается с HuggingFace.
|
|
228
|
+
self.reading_order = s.reading_order
|
|
229
|
+
if s.reading_order and not reading_order_ready():
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
"reading_order=True, but the reading-order model is not downloaded.\n"
|
|
232
|
+
"Download it: from occular import download_reading_order; download_reading_order()\n"
|
|
233
|
+
"Or check the status: from occular import model_info; model_info()"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
detector = s.detector or "dbnet-onnx"
|
|
237
|
+
# s.recognizer — обычно АРХИТЕКТУРА ('svtr_lcnet' | 'svtr_t'); имя из реестра
|
|
238
|
+
# ('crnn-onnx', 'multilingual') тоже принимается для обратной совместимости.
|
|
239
|
+
from .recognizer_onnx import RECOGNIZER_ARCHS
|
|
240
|
+
if s.recognizer and s.recognizer not in RECOGNIZER_ARCHS:
|
|
241
|
+
recognizer, rec_kwargs = s.recognizer, None # старое: имя в реестре
|
|
242
|
+
else:
|
|
243
|
+
recognizer, rec_kwargs = _normalize_languages(s.languages, s.recognizer)
|
|
244
|
+
|
|
245
|
+
self._pipeline = _OCRPipelineBase(
|
|
246
|
+
detector_name=detector,
|
|
247
|
+
recognizer_name=recognizer,
|
|
248
|
+
recognizer_kwargs=rec_kwargs,
|
|
249
|
+
deskew=s.deskew,
|
|
250
|
+
orientation=s.orientation,
|
|
251
|
+
reading_order=s.reading_order,
|
|
252
|
+
lm=s.lm,
|
|
253
|
+
num_threads=s.num_threads,
|
|
254
|
+
gpu=s.gpu,
|
|
255
|
+
unwarp=s.unwarp,
|
|
256
|
+
unwarp_gate=s.unwarp_gate,
|
|
257
|
+
unwarp_margin=s.unwarp_margin,
|
|
258
|
+
experimental_layout_order=s.experimental_layout_order,
|
|
259
|
+
layout_order_method=s.layout_order_method,
|
|
260
|
+
tables=s.tables
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def process_image(self, image_path: str) -> List[Dict]:
|
|
264
|
+
"""
|
|
265
|
+
Обработать изображение.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Список {"quad": [...], "text": str, "confidence": float}; при Settings(tables=True) у строк в
|
|
269
|
+
таблицах есть ключи 'table' и 'cell', а у списка — атрибут .tables (структура таблиц)
|
|
270
|
+
"""
|
|
271
|
+
return self._pipeline.process_image(image_path)
|
|
272
|
+
|
|
273
|
+
def process_pdf(
|
|
274
|
+
self,
|
|
275
|
+
pdf_path: str,
|
|
276
|
+
*,
|
|
277
|
+
dpi: int = 300,
|
|
278
|
+
force_ocr: bool = False,
|
|
279
|
+
workers: Optional[int] = None
|
|
280
|
+
) -> List[Dict]:
|
|
281
|
+
"""
|
|
282
|
+
Обработать PDF.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
pdf_path: путь к PDF
|
|
286
|
+
dpi: разрешение рендеринга (default: 300)
|
|
287
|
+
force_ocr: принудительно OCR даже для векторных PDF
|
|
288
|
+
workers: сколько страниц параллельно, отдельными процессами (None = авто по ядрам и памяти,
|
|
289
|
+
до 16; 1 = последовательно). Нужен `if __name__ == "__main__":` в главном скрипте —
|
|
290
|
+
иначе потоки (до 8)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Список страниц с результатами
|
|
294
|
+
"""
|
|
295
|
+
return self._pipeline.process_pdf(
|
|
296
|
+
pdf_path,
|
|
297
|
+
dpi=dpi,
|
|
298
|
+
force_ocr=force_ocr,
|
|
299
|
+
workers=workers
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def get_text(self, file_path: str, **kwargs) -> Union[str, List[str]]:
|
|
303
|
+
"""
|
|
304
|
+
Получить только текст (без координат).
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Для изображения: строка
|
|
308
|
+
Для PDF: список строк по страницам
|
|
309
|
+
"""
|
|
310
|
+
from .tables import compose_text
|
|
311
|
+
path = Path(file_path)
|
|
312
|
+
if path.suffix.lower() == '.pdf':
|
|
313
|
+
results = self.process_pdf(file_path, **kwargs)
|
|
314
|
+
return [compose_text(page["results"], page.get("tables")) for page in results]
|
|
315
|
+
else:
|
|
316
|
+
results = self.process_image(file_path)
|
|
317
|
+
return compose_text(results, getattr(results, "tables", None))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ============================================================================
|
|
321
|
+
# Экспорт
|
|
322
|
+
# ============================================================================
|
|
323
|
+
|
|
324
|
+
__all__ = [
|
|
325
|
+
# Простой API
|
|
326
|
+
"ocr",
|
|
327
|
+
"ocr_detailed",
|
|
328
|
+
# Продвинутый API
|
|
329
|
+
"OCRPipeline",
|
|
330
|
+
"Settings",
|
|
331
|
+
# Таблицы (детекция + структура)
|
|
332
|
+
"TableRecognizer",
|
|
333
|
+
# Модели: где лежат и как скачать порядок чтения (для чайников)
|
|
334
|
+
"model_info",
|
|
335
|
+
"download_reading_order",
|
|
336
|
+
"reading_order_ready",
|
|
337
|
+
# Layout-регионы (D-FINE-S): координаты, score, метка, размер страницы
|
|
338
|
+
"LayoutDetector",
|
|
339
|
+
"LayoutRegion",
|
|
340
|
+
"LayoutResult",
|
|
341
|
+
"LAYOUT_LABELS",
|
|
342
|
+
"download_layout",
|
|
343
|
+
"layout_ready",
|
|
344
|
+
"WEIGHTS_DIR",
|
|
345
|
+
# Утилиты
|
|
346
|
+
"get_optimal_workers",
|
|
347
|
+
# Низкоуровневый доступ
|
|
348
|
+
"Registry",
|
|
349
|
+
]
|
occular/__main__.py
ADDED
occular/_beam.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Beam search adapted from pyctcdecode 0.5.0 (decoder.py, language_model.py), Copyright 2021-present
|
|
2
|
+
# Kensho Technologies, LLC, licensed under the Apache License 2.0; see LICENSE-pyctcdecode.
|
|
3
|
+
# Changes: only the configuration occular uses is kept (character alphabet, n-gram LM, no hotwords,
|
|
4
|
+
# no history pruning, no multiprocessing); word-frame bookkeeping is dropped (it never affects scores).
|
|
5
|
+
"""CTC beam search with shallow n-gram LM fusion — pure Python + numpy, no third-party decoder.
|
|
6
|
+
|
|
7
|
+
Scores, tie-breaking and output are identical to ``pyctcdecode.BeamSearchDecoderCTC.decode_beams``
|
|
8
|
+
with the same model; kept in-tree so the package does not inherit pyctcdecode's ``numpy<2`` pin.
|
|
9
|
+
"""
|
|
10
|
+
import heapq
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
# pyctcdecode defaults (decoder parameters occular relies on)
|
|
16
|
+
UNK_LOGP_OFFSET = -10.0
|
|
17
|
+
BEAM_PRUNE_LOGP = -10.0
|
|
18
|
+
TOKEN_MIN_LOGP = -5.0
|
|
19
|
+
AVG_TOKEN_LEN = 6 # expected characters per word (for OOV prefix scoring)
|
|
20
|
+
MIN_TOKEN_CLIP_P = 1e-15
|
|
21
|
+
LOG_BASE_CHANGE_FACTOR = 1.0 / math.log10(math.e) # n-gram scores are log10, beam works in ln
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _log_softmax(x, axis):
|
|
25
|
+
"""Logarithm of softmax (as in scipy.special / pyctcdecode)."""
|
|
26
|
+
x_max = np.amax(x, axis=axis, keepdims=True)
|
|
27
|
+
if x_max.ndim > 0:
|
|
28
|
+
x_max[~np.isfinite(x_max)] = 0
|
|
29
|
+
elif not np.isfinite(x_max):
|
|
30
|
+
x_max = 0
|
|
31
|
+
tmp = x - x_max
|
|
32
|
+
exp_tmp = np.exp(tmp)
|
|
33
|
+
with np.errstate(divide="ignore"):
|
|
34
|
+
s = np.sum(exp_tmp, axis=axis, keepdims=True)
|
|
35
|
+
out = np.log(s)
|
|
36
|
+
return tmp - out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sum_log_scores(s1, s2):
|
|
40
|
+
if s1 >= s2:
|
|
41
|
+
return s1 + math.log(1 + math.exp(s2 - s1))
|
|
42
|
+
return s2 + math.log(1 + math.exp(s1 - s2))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _merge_tokens(token_1, token_2):
|
|
46
|
+
if len(token_2) == 0:
|
|
47
|
+
return token_1
|
|
48
|
+
if len(token_1) == 0:
|
|
49
|
+
return token_2
|
|
50
|
+
return token_1 + " " + token_2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# beam: (text, next_word, word_part, last_char, logit_score)
|
|
54
|
+
def _merge_beams(beams):
|
|
55
|
+
"""Merge beams that read the same (text, partial word, last char); sum their probabilities."""
|
|
56
|
+
beam_dict = {}
|
|
57
|
+
for text, next_word, word_part, last_char, logit_score in beams:
|
|
58
|
+
key = (_merge_tokens(text, next_word), word_part, last_char)
|
|
59
|
+
prev = beam_dict.get(key)
|
|
60
|
+
if prev is None:
|
|
61
|
+
beam_dict[key] = (text, next_word, word_part, last_char, logit_score)
|
|
62
|
+
else:
|
|
63
|
+
beam_dict[key] = (text, next_word, word_part, last_char,
|
|
64
|
+
_sum_log_scores(prev[-1], logit_score))
|
|
65
|
+
return list(beam_dict.values())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LanguageModel:
|
|
69
|
+
"""Shallow-fusion scorer over an n-gram model through the interface of ``_pylm.Model``."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, model, state_cls, unigrams, prefix_checker, alpha, beta,
|
|
72
|
+
unk_score_offset=UNK_LOGP_OFFSET, score_boundary=True):
|
|
73
|
+
self._model = model
|
|
74
|
+
self._state_cls = state_cls
|
|
75
|
+
self._unigram_set = set(unigrams)
|
|
76
|
+
self._char_trie = prefix_checker
|
|
77
|
+
self.alpha = alpha
|
|
78
|
+
self.beta = beta
|
|
79
|
+
self.unk_score_offset = unk_score_offset
|
|
80
|
+
self.score_boundary = score_boundary
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def order(self):
|
|
84
|
+
return self._model.order
|
|
85
|
+
|
|
86
|
+
def get_start_state(self):
|
|
87
|
+
st = self._state_cls()
|
|
88
|
+
if self.score_boundary:
|
|
89
|
+
self._model.BeginSentenceWrite(st)
|
|
90
|
+
else:
|
|
91
|
+
self._model.NullContextWrite(st)
|
|
92
|
+
return st
|
|
93
|
+
|
|
94
|
+
def _get_raw_end_score(self, start_state):
|
|
95
|
+
if self.score_boundary:
|
|
96
|
+
return self._model.BaseScore(start_state, "</s>", self._state_cls())
|
|
97
|
+
return 0.0
|
|
98
|
+
|
|
99
|
+
def score_partial_token(self, partial_token):
|
|
100
|
+
is_oov = int(self._char_trie.has_node(partial_token) == 0)
|
|
101
|
+
unk_score = self.unk_score_offset * is_oov
|
|
102
|
+
if len(partial_token) > AVG_TOKEN_LEN:
|
|
103
|
+
unk_score = unk_score * len(partial_token) / AVG_TOKEN_LEN
|
|
104
|
+
return unk_score
|
|
105
|
+
|
|
106
|
+
def score(self, prev_state, word, is_last_word=False):
|
|
107
|
+
end_state = self._state_cls()
|
|
108
|
+
lm_score = self._model.BaseScore(prev_state, word, end_state)
|
|
109
|
+
# same precedence as pyctcdecode: (known-set non-empty and word unknown) or word not in model
|
|
110
|
+
if len(self._unigram_set) > 0 and word not in self._unigram_set or word not in self._model:
|
|
111
|
+
lm_score += self.unk_score_offset
|
|
112
|
+
if is_last_word:
|
|
113
|
+
lm_score = lm_score + self._get_raw_end_score(end_state)
|
|
114
|
+
lm_score = self.alpha * lm_score * LOG_BASE_CHANGE_FACTOR + self.beta
|
|
115
|
+
return lm_score, end_state
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BeamSearchDecoder:
|
|
119
|
+
"""CTC beam search over a character alphabet (index 0 = blank ``""``, word separator ``" "``)."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, labels, language_model):
|
|
122
|
+
labels = list(labels)
|
|
123
|
+
if "" not in labels:
|
|
124
|
+
raise ValueError("alphabet must contain the CTC blank ''")
|
|
125
|
+
if len(labels) != len(set(labels)):
|
|
126
|
+
raise ValueError("alphabet contains duplicate entries")
|
|
127
|
+
self._idx2vocab = dict(enumerate(labels))
|
|
128
|
+
self._lm = language_model
|
|
129
|
+
|
|
130
|
+
def _get_lm_beams(self, beams, cached_lm_scores, cached_partial_token_scores, is_eos=False):
|
|
131
|
+
"""Beams with combined score appended: (text, '', word_part, last_char, logit, combined)."""
|
|
132
|
+
lm = self._lm
|
|
133
|
+
new_beams = []
|
|
134
|
+
for text, next_word, word_part, last_char, logit_score in beams:
|
|
135
|
+
new_text = _merge_tokens(text, next_word)
|
|
136
|
+
cache_key = (new_text, is_eos)
|
|
137
|
+
if cache_key not in cached_lm_scores:
|
|
138
|
+
prev_raw_lm_score, start_state = cached_lm_scores[(text, False)]
|
|
139
|
+
score, end_state = lm.score(start_state, next_word, is_last_word=is_eos)
|
|
140
|
+
cached_lm_scores[cache_key] = (prev_raw_lm_score + score, end_state)
|
|
141
|
+
lm_score = cached_lm_scores[cache_key][0]
|
|
142
|
+
if len(word_part) > 0:
|
|
143
|
+
if word_part not in cached_partial_token_scores:
|
|
144
|
+
cached_partial_token_scores[word_part] = lm.score_partial_token(word_part)
|
|
145
|
+
lm_score += cached_partial_token_scores[word_part]
|
|
146
|
+
new_beams.append((new_text, "", word_part, last_char, logit_score, logit_score + lm_score))
|
|
147
|
+
return new_beams
|
|
148
|
+
|
|
149
|
+
def _decode_logits(self, logits, beam_width, beam_prune_logp, token_min_logp):
|
|
150
|
+
cached_lm_scores = {("", False): (0.0, self._lm.get_start_state())}
|
|
151
|
+
cached_p_lm_scores = {}
|
|
152
|
+
beams = [("", "", "", None, 0.0)]
|
|
153
|
+
idx2vocab = self._idx2vocab
|
|
154
|
+
for logit_col in logits:
|
|
155
|
+
max_idx = logit_col.argmax()
|
|
156
|
+
# set iteration order matters for merge/tie-breaking — keep pyctcdecode's construction
|
|
157
|
+
idx_list = set(np.where(logit_col >= token_min_logp)[0]) | {max_idx}
|
|
158
|
+
new_beams = []
|
|
159
|
+
for idx_char in idx_list:
|
|
160
|
+
p_char = logit_col[idx_char]
|
|
161
|
+
char = idx2vocab[idx_char]
|
|
162
|
+
for text, next_word, word_part, last_char, logit_score in beams:
|
|
163
|
+
if char == "" or last_char == char: # blank or repeated char
|
|
164
|
+
new_beams.append((text, next_word, word_part, char, logit_score + p_char))
|
|
165
|
+
elif char == " ": # word boundary
|
|
166
|
+
new_beams.append((text, word_part, "", char, logit_score + p_char))
|
|
167
|
+
else: # continue the current word
|
|
168
|
+
new_beams.append((text, next_word, word_part + char, char, logit_score + p_char))
|
|
169
|
+
new_beams = _merge_beams(new_beams)
|
|
170
|
+
scored = self._get_lm_beams(new_beams, cached_lm_scores, cached_p_lm_scores)
|
|
171
|
+
max_score = max([b[-1] for b in scored])
|
|
172
|
+
scored = [b for b in scored if b[-1] >= max_score + beam_prune_logp]
|
|
173
|
+
beams = [b[:-1] for b in heapq.nlargest(beam_width, scored, key=lambda x: x[-1])]
|
|
174
|
+
|
|
175
|
+
# close the last word, score end of sentence
|
|
176
|
+
new_beams = [(text, word_part, "", None, logit_score)
|
|
177
|
+
for text, _, word_part, _, logit_score in beams]
|
|
178
|
+
new_beams = _merge_beams(new_beams)
|
|
179
|
+
scored = self._get_lm_beams(new_beams, cached_lm_scores, cached_p_lm_scores, is_eos=True)
|
|
180
|
+
max_score = max([b[-1] for b in scored])
|
|
181
|
+
scored = [b for b in scored if b[-1] >= max_score + beam_prune_logp]
|
|
182
|
+
trimmed = heapq.nlargest(beam_width, scored, key=lambda x: x[-1])
|
|
183
|
+
return [(" ".join(text.split()), logit_score, combined_score)
|
|
184
|
+
for text, _, _, _, logit_score, combined_score in trimmed]
|
|
185
|
+
|
|
186
|
+
def decode_beams(self, logits, beam_width=100, beam_prune_logp=BEAM_PRUNE_LOGP,
|
|
187
|
+
token_min_logp=TOKEN_MIN_LOGP):
|
|
188
|
+
"""logits [T, C] -> beams sorted best-first: (text, acoustic_logit_score, combined_score)."""
|
|
189
|
+
if logits.ndim != 2 or logits.shape[-1] != len(self._idx2vocab):
|
|
190
|
+
raise ValueError(f"logits must be (time, {len(self._idx2vocab)}), got {logits.shape}")
|
|
191
|
+
if math.isclose(logits.sum(axis=1).mean(), 1): # probabilities -> log probs
|
|
192
|
+
logits = np.log(np.clip(logits, MIN_TOKEN_CLIP_P, 1))
|
|
193
|
+
else:
|
|
194
|
+
logits = np.clip(_log_softmax(logits, axis=1), np.log(MIN_TOKEN_CLIP_P), 0)
|
|
195
|
+
return self._decode_logits(logits, beam_width, beam_prune_logp, token_min_logp)
|