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.
Files changed (46) hide show
  1. _occular_vcrt.py +47 -0
  2. occular/__init__.py +349 -0
  3. occular/__main__.py +6 -0
  4. occular/_beam.py +195 -0
  5. occular/_envcheck.py +93 -0
  6. occular/_langid.py +50 -0
  7. occular/_pylm.py +115 -0
  8. occular/_runtime.py +98 -0
  9. occular/_vcrt/NOTICE.txt +8 -0
  10. occular/_vcrt/_occular_vcrt.py +47 -0
  11. occular/_vcrt/msvcp140.dll +0 -0
  12. occular/_vcrt/msvcp140_1.dll +0 -0
  13. occular/_vcrt/msvcp140_atomic_wait.dll +0 -0
  14. occular/_vcrt/vcruntime140.dll +0 -0
  15. occular/_vcrt/vcruntime140_1.dll +0 -0
  16. occular/_winrt.py +15 -0
  17. occular/cli.py +326 -0
  18. occular/code_lock.py +96 -0
  19. occular/cyr_decode_config.json +82 -0
  20. occular/dbnet_detector_onnx.py +146 -0
  21. occular/decoder_lm.py +182 -0
  22. occular/deskew.py +60 -0
  23. occular/layout.py +119 -0
  24. occular/layout_order.py +120 -0
  25. occular/layout_order_model.py +149 -0
  26. occular/model_files.py +228 -0
  27. occular/multilingual.py +169 -0
  28. occular/orientation.py +94 -0
  29. occular/orientation_geom.py +264 -0
  30. occular/orientation_resolve.py +182 -0
  31. occular/parallel.py +247 -0
  32. occular/pipeline.py +589 -0
  33. occular/reading_order.py +79 -0
  34. occular/recognizer_onnx.py +189 -0
  35. occular/registry.py +62 -0
  36. occular/settings.py +89 -0
  37. occular/tables.py +530 -0
  38. occular/unwarp_uvdoc.py +66 -0
  39. occular-vcrt.pth +1 -0
  40. occular_ocr-0.5.0.dist-info/METADATA +1009 -0
  41. occular_ocr-0.5.0.dist-info/RECORD +46 -0
  42. occular_ocr-0.5.0.dist-info/WHEEL +5 -0
  43. occular_ocr-0.5.0.dist-info/entry_points.txt +2 -0
  44. occular_ocr-0.5.0.dist-info/licenses/LICENSE +202 -0
  45. occular_ocr-0.5.0.dist-info/licenses/LICENSE-pyctcdecode +205 -0
  46. 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
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m occular`"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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)