Просмотр исходного кода

pipeline optimization, new parameters (see README.md)

master
Evgeniy Ierusalimov 4 дней назад
Родитель
Сommit
b5e5b7f394
12 измененных файлов: 772 добавлений и 405 удалений
  1. 4
    0
      .env.dist
  2. 32
    10
      README.md
  3. 46
    81
      installation.md
  4. 118
    0
      requirements.txt
  5. 267
    0
      src/image_ocr.py
  6. 0
    166
      src/image_to_latex.py
  7. 146
    0
      src/latex/json_to_latex.py
  8. 23
    13
      src/ocr/surya_engine.py
  9. 2
    2
      src/postprocess/llm_corrector.py
  10. 9
    9
      src/postprocess/vlm_corrector.py
  11. 6
    0
      src/split/slicer.py
  12. 119
    124
      stage2_results.md

+ 4
- 0
.env.dist Просмотреть файл

@@ -4,3 +4,7 @@ OPENCODE_API_KEY=sk-...
4 4
 # HuggingFace — токен для скачивания моделей (Surya 2, Qwen GGUF)
5 5
 # Получить: https://huggingface.co/settings/tokens (read-only)
6 6
 HF_TOKEN=hf_...
7
+
8
+# Debug-лог OCR (необязательно). Путь к файлу для записи всех событий.
9
+# Если не задан — только консольный вывод.
10
+# OCR_LOG_FILE=image_ocr.log

+ 32
- 10
README.md Просмотреть файл

@@ -31,11 +31,13 @@ src/image_split.py          ← Stage 1: разрезание
31 31
 JPEG страницы
32 32
33 33
34
-src/image_to_latex.py       ← Stage 2: OCR + Markdown
34
+src/image_ocr.py           ← Stage 2: OCR + Markdown (бывший image_to_latex.py)
35 35
     │ --ocr-engine paddle|surya
36 36
     │ --input file.jpg         (одна страница)
37 37
     │ --input pages/           (пакетный режим: все изображения в каталоге)
38
-    │ --llm, --vlm (optional)
38
+    │ --llm, --vlm, --force-ocr
39
+    │ --pause 5 (по умолчанию)
40
+    │ --result-one-document    (по умолчанию ON, объединить в один .md)
39 41
40 42
     ├─ PP-StructureV3 / Surya 2    (OCR)
41 43
     ├─ fixups                      (VaR, V^2)
@@ -68,7 +70,7 @@ python -m src.image_split -i scan.jpg -s 3:3 -b 10 -o pages/
68 70
 python -m src.image_split -i scan.jpg --slice-auto
69 71
 ```
70 72
 
71
-### Stage 2: `src/image_to_latex.py`
73
+### Stage 2: `src/image_ocr.py`
72 74
 
73 75
 | Параметр | Описание | По умолчанию |
74 76
 |----------|----------|:---:|
@@ -78,20 +80,37 @@ python -m src.image_split -i scan.jpg --slice-auto
78 80
 | `--ocr-engine` | `paddle` или `surya` | `paddle` |
79 81
 | `--llm` | Локальный LLM-корректор (Qwen2.5-7B) | OFF |
80 82
 | `--vlm` | Vision LM корректор (Qwen3.8 Max, API) | OFF |
83
+| `--force-ocr` | Перераспознать даже при наличии `.json` | OFF |
84
+| `--pause` | Пауза между изображениями (сек) | 5 |
85
+| `--result-one-document` | Объединить `.md` в один документ | ON |
86
+| `--no-result-one-document` | Не объединять | — |
81 87
 
82 88
 Пример:
83 89
 ```bash
84 90
 # Одна страница
85
-python -m src.image_to_latex -i page.jpg
91
+python -m src.image_ocr -i page.jpg
86 92
 
87
-# Пакетный режим — все изображения в каталоге (Surya: batch-оптимизация)
93
+# Пакетный режим — все изображения в каталоге
94
+python -m src.image_ocr -i ./pages/ --ocr-engine surya
88 95
 
89
-python -m src.image_to_latex -i ./pages/ --ocr-engine surya
96
+# Принудительное перераспознавание
97
+python -m src.image_ocr -i ./pages/ --force-ocr
90 98
 
91
-# Пакетный режим (PaddleOCR: последовательно)
92
-python -m src.image_to_latex -i ./pages/ --ocr-engine paddle
99
+# Без объединения в один документ
100
+python -m src.image_ocr -i ./pages/ --no-result-one-document
93 101
 ```
94 102
 
103
+### Debug-лог
104
+
105
+Для отладки зависаний и падений — файл с полным логом всех этапов обработки.
106
+В `.env` добавить:
107
+
108
+```
109
+OCR_LOG_FILE=image_ocr.log
110
+```
111
+
112
+Формат вывода: `ЧЧ:ММ:СС [LEVEL] сообщение`. Файл пишется рядом со скриптом или по указанному пути. Уровень DEBUG показывает: начало/конец OCR, сохранение JSON/MD, вызов корректоров.
113
+
95 114
 ### Переменные окружения (`.env`)
96 115
 
97 116
 Скопируй шаблон и заполни своими ключами:
@@ -105,6 +124,7 @@ cp .env.dist .env
105 124
 |-----------|-----------|-----------|
106 125
 | `OPENCODE_API_KEY` | VLM-корректор через OpenCode Go | [opencode.ai/auth](https://opencode.ai/auth) → скопировать ключ |
107 126
 | `HF_TOKEN` | Скачивание моделей с HuggingFace (Surya 2) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (read-only) |
127
+| `OCR_LOG_FILE` | Путь к debug-логу (необязательно) | Например: `image_ocr.log` |
108 128
 
109 129
 `.env` уже добавлен в `.gitignore` — не коммитится.
110 130
 
@@ -112,7 +132,7 @@ cp .env.dist .env
112 132
 
113 133
 ## Результат
114 134
 
115
-CLI (`image_to_latex.py`) генерирует два файла для каждого изображения:
135
+CLI (`image_ocr.py`) генерирует два файла для каждого изображения:
116 136
 
117 137
 | Файл | Формат | Описание |
118 138
 |------|--------|----------|
@@ -220,7 +240,7 @@ llama-server не указывает количество потоков — п
220 240
 ```
221 241
 src/
222 242
   image_split.py         — CLI Stage 1
223
-  image_to_latex.py      — CLI Stage 2
243
+  image_ocr.py          — CLI Stage 2 (бывший image_to_latex.py)
224 244
   split/slicer.py        — разрезание, post-crop
225 245
   ocr/
226 246
     paddle_engine.py     — PP-StructureV3
@@ -233,6 +253,8 @@ src/
233 253
   markdown/generator.py  — Markdown + валидация
234 254
   latex/
235 255
     surya2html.py        — Surya JSON → HTML
256
+    surya2latex.py       — Surya JSON → LaTeX
257
+    json_to_latex.py     — Единый JSON→LaTeX (Paddle + Surya)
236 258
     tex2html.py           — TeX → HTML
237 259
 ```
238 260
 

+ 46
- 81
installation.md Просмотреть файл

@@ -2,129 +2,94 @@
2 2
 
3 3
 ## Системные требования
4 4
 
5
-- **OS:** Linux (x86_64)
6
-- **CPU:** 8+ ядер (рекомендуется)
7
-- **RAM:** 16+ GB (32 GB для LLM-корректора)
8
-- **Диск:** 10+ GB свободного места
5
+- **OS:** Linux x86_64
6
+- **CPU:** 8+ ядер (i7-1165G7 или лучше)
7
+- **RAM:** 16+ GB (32 GB с LLM-корректором)
8
+- **Диск:** 15+ GB свободного места
9 9
 
10 10
 ---
11 11
 
12
-## Шаг 1: Python 3.12 + venv
12
+## Шаг 1: Python 3.12 + venv + все зависимости
13 13
 
14 14
 ```bash
15
-python3 -m venv .venv
15
+python3.12 -m venv .venv
16 16
 source .venv/bin/activate
17
+pip install --upgrade pip
18
+pip install -r requirements.txt
17 19
 ```
18 20
 
21
+**Важно:** PaddlePaddle — версия **3.2.2** (3.3.1 содержит баг PIR/ONEDNN на CPU).
22
+
19 23
 ---
20 24
 
21
-## Шаг 2: Stage 1 — разрезание изображений
25
+## Шаг 2: llama-server (для Surya 2 на CPU)
22 26
 
23
-```bash
24
-pip install opencv-python-headless typer
25
-```
27
+Surya 2 использует llama.cpp сервер. Нужен бинарник `llama-server`:
26 28
 
27
-Зависимости: `opencv-python-headless`, `numpy`, `typer`, `ruff` (dev).
28
-
29
-Проверка:
30 29
 ```bash
31
-python -m src.image_split --help
32
-```
33
-
34
----
35
-
36
-## Шаг 3: Stage 2 — OCR (PaddleOCR)
30
+mkdir -p ~/.local/bin
37 31
 
38
-```bash
39
-pip install paddlepaddle==3.2.2
40
-pip install paddleocr pyyaml
41
-```
32
+# Скачать последний релиз (URL обновить из https://github.com/ggml-org/llama.cpp/releases/latest)
33
+curl -L "https://github.com/ggml-org/llama.cpp/releases/download/b10268/llama-b10268-bin-ubuntu-x64.tar.gz" \
34
+  -o /tmp/llama.tar.gz
42 35
 
43
-Модели загружаются автоматически при первом запуске в `~/.paddlex/official_models/` (~2.5 GB).
36
+tar -xzf /tmp/llama.tar.gz -C /tmp/
37
+cp /tmp/llama-b10268/llama-server /tmp/llama-b10268/lib*.so* ~/.local/bin/
44 38
 
45
-Проверка:
46
-```bash
47
-python -m src.image_to_latex --help
39
+export PATH="$HOME/.local/bin:$PATH"
40
+llama-server --version  # проверка
48 41
 ```
49 42
 
50 43
 ---
51 44
 
52
-## Шаг 4 (опционально): LLM-корректор (Qwen2.5-7B)
45
+## Шаг 3: Конфигурация (.env)
53 46
 
54 47
 ```bash
55
-pip install llama-cpp-python
48
+cp .env.dist .env
49
+# Отредактировать .env — вставить токены
56 50
 ```
57 51
 
58
-Модель загружается из HuggingFace при первом запуске (~4.4 GB):
59
-```bash
60
-# Автоматически при первом --llm, или вручную:
61
-curl -L "https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf" \
62
-  -o ~/.cache/llama_models/Qwen2.5-7B-Instruct-Q4_K_M.gguf
63
-```
64
-
65
-Использование:
66
-```bash
67
-python -m src.image_to_latex -i page.jpg --llm
68
-```
52
+| Переменная | Назначение |
53
+|-----------|-----------|
54
+| `OPENCODE_API_KEY` | API-ключ OpenCode Go для VLM-корректора |
55
+| `HF_TOKEN` | HuggingFace токен (read-only) для скачивания моделей |
56
+| `OCR_LOG_FILE` | Путь к debug-логу (необязательно) |
69 57
 
70 58
 ---
71 59
 
72
-## Шаг 5 (опционально): VLM-корректор (Qwen3.8 Max)
60
+## Шаг 4: Модели (загружаются автоматически при первом запуске)
73 61
 
74
-Требуется подписка OpenCode Go. Токен записать в `.env`:
62
+При первом запуске скрипты скачают нужные модели. Для ускорения можно предварительно задать `HF_TOKEN` в `.env`.
75 63
 
76
-```bash
77
-echo 'OPENCODE_API_KEY=sk-...' > .env
78
-echo 'HF_TOKEN=hf_...' >> .env
79
-```
80
-
81
-`.env` уже добавлен в `.gitignore`.
82
-
83
-Использование:
84
-```bash
85
-python -m src.image_to_latex -i page.jpg --vlm
86
-```
64
+| Движок | Модели | Размер | Куда |
65
+|--------|--------|:---:|------|
66
+| PaddleOCR | PP-StructureV3 (ансамбль) | ~2.5 GB | `~/.paddlex/official_models/` |
67
+| Surya 2 | surya-2.gguf + mmproj | ~1.5 GB | HF Hub (кеш) |
68
+| LLM | Qwen2.5-7B-Q4_K_M.gguf | ~4.4 GB | `~/.cache/llama_models/` |
87 69
 
88 70
 ---
89 71
 
90
-## Шаг 6 (опционально): Surya 2
91
-
92
-Surya 2 требует отдельного venv из-за конфликтов зависимостей с PaddleOCR.
72
+## Проверка
93 73
 
94 74
 ```bash
95
-# Установка llama-server (CPU-версия)
96
-mkdir -p ~/.local/bin
97
-curl -L "${URL}" -o /tmp/llama.tar.gz
98
-tar -xzf /tmp/llama.tar.gz -C /tmp/
99
-cp /tmp/llama-*/llama-server /tmp/llama-*/lib*.so* ~/.local/bin/
100
-export PATH="$HOME/.local/bin:$PATH"
75
+# Stage 1
76
+python -m src.image_split --help
101 77
 
102
-# Установка пакетов (в основном venv — конфликтует с PaddleOCR)
103
-pip install surya-ocr torch torchvision --index-url https://download.pytorch.org/whl/cpu
104
-pip install "opencv-python-headless==4.11.0.86" "pillow<11" transformers platformdirs
105
-```
78
+# Stage 2 — PaddleOCR
79
+python -m src.image_ocr -i test.jpg
106 80
 
107
-Использование:
108
-```bash
109
-python -m src.image_to_latex -i page.jpg --ocr-engine surya
81
+# Stage 2 — Surya
82
+python -m src.image_ocr -i test.jpg --ocr-engine surya
83
+
84
+# Stage 2 — с LLM-корректором
85
+python -m src.image_ocr -i test.jpg --llm
110 86
 ```
111 87
 
112 88
 ---
113 89
 
114
-## Шаг 7: Разработка
90
+## Разработка
115 91
 
116 92
 ```bash
117
-pip install ruff pytest
118 93
 ruff check src/ tests/
119 94
 python -m pytest tests/ -v
120 95
 ```
121
-
122
----
123
-
124
-## Структура моделей (локально)
125
-
126
-```
127
-~/.paddlex/official_models/          PaddleOCR (~2.5 GB)
128
-~/.cache/llama_models/               LLM (~4.4 GB)
129
-/tmp/hf_cache/                       Surya 2 (~1.5 GB, HuggingFace)
130
-```

+ 118
- 0
requirements.txt Просмотреть файл

@@ -0,0 +1,118 @@
1
+aiohappyeyeballs==2.7.1
2
+aiohttp==3.14.3
3
+aiosignal==1.4.0
4
+aistudio_sdk==0.3.9
5
+annotated-doc==0.0.5
6
+annotated-types==0.8.0
7
+anyio==4.14.2
8
+attrs==26.1.0
9
+bce-python-sdk==0.9.76
10
+beautifulsoup4==4.15.0
11
+cachetools==7.1.7
12
+certifi==2026.7.22
13
+chardet==7.4.3
14
+charset-normalizer==3.4.9
15
+click==8.4.2
16
+colorlog==6.12.0
17
+crc32c==2.8
18
+cssselect==1.5.0
19
+cssutils==2.15.0
20
+diskcache==5.6.3
21
+distro==1.9.0
22
+docling==2.117.0
23
+einops==0.8.2
24
+encutils==1.0.0
25
+et_xmlfile==2.0.0
26
+filelock==3.32.2
27
+filetype==1.2.0
28
+frozenlist==1.8.0
29
+fsspec==2026.7.0
30
+ftfy==6.3.1
31
+future==1.0.0
32
+h11==0.16.0
33
+hf-xet==1.5.2
34
+httpcore==1.0.9
35
+httpx==0.28.1
36
+huggingface_hub==1.26.0
37
+idna==3.18
38
+imagesize==2.0.0
39
+iniconfig==2.3.0
40
+Jinja2==3.1.6
41
+jiter==0.16.0
42
+joblib==1.5.3
43
+latex2mathml==3.81.0
44
+llama_cpp_python==0.3.34
45
+lxml==6.1.1
46
+markdown-it-py==4.2.0
47
+MarkupSafe==3.0.3
48
+mdurl==0.1.2
49
+modelscope==1.39.0
50
+modelscope-hub==0.2.0
51
+more-itertools==11.1.0
52
+mpmath==1.3.0
53
+multidict==6.7.1
54
+narwhals==2.24.0
55
+networkx==3.6.1
56
+numpy==2.3.5
57
+openai==2.53.0
58
+opencv-contrib-python==4.10.0.84
59
+opencv-python-headless==4.11.0.86
60
+openpyxl==3.1.5
61
+opt-einsum==3.3.0
62
+packaging==26.2
63
+paddleocr==3.7.0
64
+paddlepaddle==3.2.2
65
+paddlex==3.7.2
66
+pandas==3.0.5
67
+pdftext==0.7.1
68
+pillow==10.4.0
69
+platformdirs==4.11.0
70
+pluggy==1.6.0
71
+premailer==3.10.0
72
+prettytable==3.18.0
73
+propcache==0.5.2
74
+protobuf==7.35.1
75
+psutil==7.2.2
76
+py-cpuinfo==9.0.0
77
+pyclipper==1.4.0
78
+pycryptodome==3.23.0
79
+pydantic==2.13.4
80
+pydantic-settings==2.14.2
81
+pydantic_core==2.46.4
82
+Pygments==2.20.0
83
+pypdfium2==5.10.1
84
+pytest==9.1.1
85
+python-bidi==0.6.11
86
+python-dateutil==2.9.0.post0
87
+python-dotenv==1.2.2
88
+PyYAML==6.0.2
89
+regex==2026.7.19
90
+requests==2.34.2
91
+rich==15.0.0
92
+ruamel.yaml==0.19.1
93
+ruff==0.16.1
94
+safetensors==0.8.0
95
+scikit-learn==1.9.0
96
+scipy==1.18.0
97
+sentencepiece==0.2.2
98
+setuptools==83.0.0
99
+shapely==2.1.2
100
+shellingham==1.5.4
101
+six==1.17.0
102
+sniffio==1.3.1
103
+soupsieve==2.9.1
104
+surya-ocr==0.22.1
105
+sympy==1.14.0
106
+threadpoolctl==3.6.0
107
+tiktoken==0.13.0
108
+tokenizers==0.22.2
109
+torch==2.13.0+cpu
110
+tqdm==4.70.0
111
+transformers==5.14.1
112
+typer==0.27.0
113
+typing-inspection==0.4.2
114
+typing_extensions==4.16.0
115
+ujson==5.13.0
116
+urllib3==2.7.0
117
+wcwidth==0.8.2
118
+yarl==1.24.5

+ 267
- 0
src/image_ocr.py Просмотреть файл

@@ -0,0 +1,267 @@
1
+from __future__ import annotations
2
+
3
+import functools
4
+import json
5
+import logging
6
+import os
7
+import time
8
+from pathlib import Path
9
+from typing import Annotated
10
+
11
+import typer
12
+
13
+from src.cli_utils import run_main
14
+from src.env import load_env
15
+from src.markdown.generator import generate_markdown
16
+from src.ocr.paddle_engine import ocr_image
17
+from src.postprocess.fixups import fix_ocr_errors
18
+
19
+load_env()
20
+
21
+# Подавление китайских иероглифов и ANSI-кодов из PaddleOCR/paddlex
22
+os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
23
+os.environ.setdefault("DISABLE_PROGRESS_BAR", "True")
24
+os.environ.setdefault("PADDLESEG_NO_PROGRESS", "1")
25
+os.environ.setdefault("LOG_LEVEL", "2")  # WARNING для Paddle внутренных логов
26
+
27
+# Приглушаем шумные библиотеки
28
+for _mod in ("paddle", "paddlex", "paddleocr", "modelscope", "huggingface_hub", "urllib3"):
29
+    logging.getLogger(_mod).setLevel(logging.WARNING)
30
+
31
+# Настройка логирования
32
+_log_format = logging.Formatter("%(asctime)s [%(levelname)-5s] %(message)s", datefmt="%H:%M:%S")
33
+_console = logging.StreamHandler()
34
+_console.setFormatter(_log_format)
35
+_root = logging.getLogger()
36
+_root.handlers.clear()
37
+_root.setLevel(logging.INFO)
38
+_root.addHandler(_console)
39
+
40
+_log_file = os.environ.get("OCR_LOG_FILE", "")
41
+if _log_file:
42
+    _fh = logging.FileHandler(_log_file, encoding="utf-8")
43
+    _fh.setLevel(logging.DEBUG)
44
+    _fh.setFormatter(_log_format)
45
+    _root.addHandler(_fh)
46
+
47
+logger = logging.getLogger(__name__)
48
+app = typer.Typer(add_completion=False, no_args_is_help=True)
49
+
50
+MD_EXTENSION: str = ".md"
51
+DEFAULT_LANG: str = "en"
52
+IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
53
+
54
+
55
+def _get_ocr_engine(name: str):
56
+    if name == "surya":
57
+        from src.ocr.surya_engine import surya_ocr_image
58
+
59
+        return surya_ocr_image, "Surya 2 VLM"
60
+    return ocr_image, "PP-StructureV3"
61
+
62
+
63
+def _merge_markdown_files(output_dir: Path, md_files: list[Path]) -> None:
64
+    merged_parts: list[str] = []
65
+    for md_path in sorted(md_files):
66
+        content = md_path.read_text(encoding="utf-8")
67
+        merged_parts.append(f"<!-- page: {md_path.stem} -->\n\n{content}")
68
+    if merged_parts:
69
+        merged = "# Merged document\n\n" + "\n\n---\n\n".join(merged_parts)
70
+        merged_path = output_dir / (output_dir.name + MD_EXTENSION)
71
+        merged_path.write_text(merged, encoding="utf-8")
72
+        logger.info("Объединённый документ: %s", merged_path)
73
+
74
+
75
+def _process_single(
76
+    ocr_fn, input_path: Path, lang: str,
77
+    output_dir_resolved: Path, use_llm: bool, use_vlm: bool,
78
+) -> None:
79
+    """Обрабатывает одно изображение: OCR → fixups → корректоры → JSON + Markdown."""
80
+    # Этап OCR — самое долгое и хрупкое место
81
+    logger.debug("OCR: начало %s", input_path.name)
82
+    t0 = time.time()
83
+    page = ocr_fn(str(input_path))
84
+    dt = time.time() - t0
85
+    logger.info("Распознано %d блоков за %.1fs", len(page.blocks), dt)
86
+    logger.debug("OCR: завершено %s (блоков: %d)", input_path.name, len(page.blocks))
87
+
88
+    # Исправление типичных OCR-ошибок (VaR, V^2)
89
+    fix_ocr_errors(page.blocks)
90
+    logger.debug("Fixups: завершено")
91
+
92
+    # Опциональная коррекция через VLM / локальный LLM
93
+    if use_vlm or use_llm:
94
+        from src.postprocess.corrector import apply_corrector
95
+        logger.debug("Корректоры: инициализация")
96
+
97
+    if use_vlm:
98
+        from src.postprocess.vlm_corrector import vlm_correct_block
99
+        corrector = functools.partial(vlm_correct_block, image_path=str(input_path.resolve()))
100
+        apply_corrector(page.blocks, corrector, "VLM")
101
+        logger.debug("VLM: завершено")
102
+
103
+    if use_llm:
104
+        from src.postprocess.llm_corrector import llm_correct_block
105
+        apply_corrector(page.blocks, llm_correct_block, "LLM")
106
+        logger.debug("LLM: завершено")
107
+
108
+    # Сохранение JSON (всегда) + Markdown (всегда)
109
+    json_path = output_dir_resolved / (input_path.stem + ".json")
110
+    if not json_path.exists():
111
+        # Если raw_json пуст (Surya), строим из ParsedBlock
112
+        json_payload = page.raw_json or {
113
+            "blocks": [{"label": b.label, "content": b.content, "bbox": list(b.bbox)}
114
+                       for b in page.blocks]
115
+        }
116
+        json_path.write_text(json.dumps(json_payload, ensure_ascii=False, indent=2), encoding="utf-8")
117
+        logger.debug("JSON сохранён: %s", json_path)
118
+
119
+    md_path = output_dir_resolved / (input_path.stem + MD_EXTENSION)
120
+    generate_markdown(page.blocks, md_path)
121
+    logger.debug("Markdown сохранён: %s", md_path)
122
+    logger.info("%s → %s, %s", input_path.name, md_path.name, json_path.name)
123
+
124
+
125
+@app.command()
126
+def image_to_latex(
127
+    input: Annotated[
128
+        Path,
129
+        typer.Option(
130
+            "--input",
131
+            "-i",
132
+            exists=True,
133
+            file_okay=True,
134
+            dir_okay=True,
135
+            readable=True,
136
+            help="Путь к входному изображению или каталогу (JPEG, PNG, TIFF)",
137
+        ),
138
+    ],
139
+    output_dir: Annotated[
140
+        Path | None,
141
+        typer.Option(
142
+            "--output-dir",
143
+            "-o",
144
+            file_okay=False,
145
+            dir_okay=True,
146
+            writable=True,
147
+            help="Каталог для сохранения результатов (по умолчанию — рядом с входным файлом)",
148
+        ),
149
+    ] = None,
150
+    lang: Annotated[
151
+        str,
152
+        typer.Option(
153
+            "--lang",
154
+            "-l",
155
+            help="Язык OCR (en, ru, ch, ...)",
156
+        ),
157
+    ] = DEFAULT_LANG,
158
+    ocr_engine: Annotated[
159
+        str,
160
+        typer.Option(
161
+            "--ocr-engine",
162
+            help="OCR-движок: paddle (PP-StructureV3) или surya (Surya 2 VLM)",
163
+        ),
164
+    ] = "paddle",
165
+    use_llm: Annotated[
166
+        bool,
167
+        typer.Option(
168
+            "--llm",
169
+            help="Использовать локальный LLM-корректор (Qwen2.5-7B GGUF) для исправления OCR-ошибок",
170
+        ),
171
+    ] = False,
172
+    use_vlm: Annotated[
173
+        bool,
174
+        typer.Option(
175
+            "--vlm",
176
+            help="Использовать vision LM (Qwen3.8 Max) для распознавания сомнительных регионов. Токен: OPENCODE_API_KEY",
177
+        ),
178
+    ] = False,
179
+    result_one_document: Annotated[
180
+        bool,
181
+        typer.Option(
182
+            "--result-one-document / --no-result-one-document",
183
+            help="Объединить все .md результаты в один многостраничный документ (по умолчанию — да)",
184
+        ),
185
+    ] = True,
186
+    force_ocr: Annotated[
187
+        bool,
188
+        typer.Option(
189
+            "--force-ocr",
190
+            help="Принудительно перераспознать все изображения, игнорируя уже существующие .json",
191
+        ),
192
+    ] = False,
193
+    pause: Annotated[
194
+        int,
195
+        typer.Option(
196
+            "--pause",
197
+            min=0,
198
+            max=600,
199
+            help="Пауза между изображениями в секундах (по умолчанию 5)",
200
+        ),
201
+    ] = 5,
202
+) -> None:
203
+    """OCR → PostProcess → Markdown. Принимает файл или каталог изображений."""
204
+    ocr_fn, engine_name = _get_ocr_engine(ocr_engine)
205
+
206
+    if input.is_dir():
207
+        image_paths = sorted(
208
+            p for p in input.iterdir()
209
+            if p.suffix.lower() in IMG_EXTENSIONS and p.is_file()
210
+        )
211
+        if not image_paths:
212
+            raise typer.BadParameter(f"Нет изображений в каталоге: {input}")
213
+
214
+        out = output_dir.resolve() if output_dir else input.resolve()
215
+        out.mkdir(parents=True, exist_ok=True)
216
+
217
+        skipped = 0
218
+        if not force_ocr:
219
+            filtered: list[Path] = []
220
+            for p in image_paths:
221
+                if (out / (p.stem + ".json")).exists():
222
+                    logger.info("Пропуск %s (уже есть .json)", p.name)
223
+                    skipped += 1
224
+                else:
225
+                    filtered.append(p)
226
+            image_paths = filtered
227
+
228
+        if not image_paths:
229
+            logger.info("Все изображения уже обработаны (пропущено %d)", skipped)
230
+            return
231
+
232
+        logger.info(
233
+            "Пакетная обработка %d изображений через %s (пропущено %d)",
234
+            len(image_paths), engine_name, skipped,
235
+        )
236
+
237
+        total_start = time.time()
238
+
239
+        for i, img_path in enumerate(image_paths):
240
+            logger.info("[%d/%d] %s...", i + 1 + skipped, len(image_paths) + skipped, img_path.name)
241
+            _process_single(ocr_fn, img_path, lang, out, use_llm, use_vlm)
242
+            if i < len(image_paths) - 1 and pause > 0:
243
+                time.sleep(pause)
244
+
245
+        total_dt = time.time() - total_start
246
+        processed = len(image_paths)
247
+        avg_dt = total_dt / processed if processed else 0
248
+
249
+        if result_one_document:
250
+            _merge_markdown_files(out, sorted(out.glob("*.md")))
251
+
252
+        logger.info(
253
+            "Готово: %d изображений, всего %.1fs, среднее %.1fs/изобр",
254
+            processed, total_dt, avg_dt,
255
+        )
256
+    else:
257
+        out = output_dir.resolve() if output_dir else input.resolve().parent
258
+        out.mkdir(parents=True, exist_ok=True)
259
+        _process_single(ocr_fn, input, lang, out, use_llm, use_vlm)
260
+
261
+
262
+def main() -> None:
263
+    run_main(app)
264
+
265
+
266
+if __name__ == "__main__":
267
+    main()

+ 0
- 166
src/image_to_latex.py Просмотреть файл

@@ -1,166 +0,0 @@
1
-from __future__ import annotations
2
-
3
-import functools
4
-import json
5
-import logging
6
-import time
7
-from pathlib import Path
8
-from typing import Annotated
9
-
10
-import typer
11
-
12
-from src.cli_utils import run_main
13
-from src.markdown.generator import generate_markdown
14
-from src.ocr.paddle_engine import ocr_image
15
-from src.postprocess.fixups import fix_ocr_errors
16
-
17
-logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
18
-logger = logging.getLogger(__name__)
19
-app = typer.Typer(add_completion=False, no_args_is_help=True)
20
-
21
-MD_EXTENSION: str = ".md"
22
-DEFAULT_LANG: str = "en"
23
-IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
24
-
25
-
26
-def _get_ocr_engine(name: str):
27
-    if name == "surya":
28
-        from src.ocr.surya_engine import surya_ocr_batch, surya_ocr_image
29
-
30
-        return surya_ocr_image, surya_ocr_batch, "Surya 2 VLM"
31
-    return ocr_image, None, "PP-StructureV3"
32
-
33
-
34
-def _process_single(
35
-    ocr_fn, input_path: Path, lang: str,
36
-    output_dir_resolved: Path, use_llm: bool, use_vlm: bool,
37
-) -> None:
38
-    t0 = time.time()
39
-    page = ocr_fn(str(input_path))
40
-    dt = time.time() - t0
41
-    logger.info("Распознано %d блоков за %.1fs", len(page.blocks), dt)
42
-
43
-    fix_ocr_errors(page.blocks)
44
-
45
-    if use_vlm or use_llm:
46
-        from src.postprocess.corrector import apply_corrector
47
-
48
-    if use_vlm:
49
-        from src.postprocess.vlm_corrector import vlm_correct_block
50
-
51
-        corrector = functools.partial(vlm_correct_block, image_path=str(input_path.resolve()))
52
-        apply_corrector(page.blocks, corrector, "VLM")
53
-
54
-    if use_llm:
55
-        from src.postprocess.llm_corrector import llm_correct_block
56
-
57
-        apply_corrector(page.blocks, llm_correct_block, "LLM")
58
-
59
-    output_name = input_path.stem + MD_EXTENSION
60
-    generate_markdown(page.blocks, output_dir_resolved / output_name)
61
-    logger.info("Markdown: %s", output_dir_resolved / output_name)
62
-
63
-    if page.raw_json:
64
-        json_path = output_dir_resolved / (input_path.stem + ".json")
65
-        json_path.write_text(json.dumps(page.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
66
-
67
-
68
-@app.command()
69
-def image_to_latex(
70
-    input: Annotated[
71
-        Path,
72
-        typer.Option(
73
-            "--input",
74
-            "-i",
75
-            exists=True,
76
-            file_okay=True,
77
-            dir_okay=True,
78
-            readable=True,
79
-            help="Путь к входному изображению или каталогу (JPEG, PNG, TIFF)",
80
-        ),
81
-    ],
82
-    output_dir: Annotated[
83
-        Path | None,
84
-        typer.Option(
85
-            "--output-dir",
86
-            "-o",
87
-            file_okay=False,
88
-            dir_okay=True,
89
-            writable=True,
90
-            help="Каталог для сохранения результатов (по умолчанию — рядом с входным файлом)",
91
-        ),
92
-    ] = None,
93
-    lang: Annotated[
94
-        str,
95
-        typer.Option(
96
-            "--lang",
97
-            "-l",
98
-            help="Язык OCR (en, ru, ch, ...)",
99
-        ),
100
-    ] = DEFAULT_LANG,
101
-    ocr_engine: Annotated[
102
-        str,
103
-        typer.Option(
104
-            "--ocr-engine",
105
-            help="OCR-движок: paddle (PP-StructureV3) или surya (Surya 2 VLM)",
106
-        ),
107
-    ] = "paddle",
108
-    use_llm: Annotated[
109
-        bool,
110
-        typer.Option(
111
-            "--llm",
112
-            help="Использовать локальный LLM-корректор (Qwen2.5-7B GGUF) для исправления OCR-ошибок",
113
-        ),
114
-    ] = False,
115
-    use_vlm: Annotated[
116
-        bool,
117
-        typer.Option(
118
-            "--vlm",
119
-            help="Использовать vision LM (Qwen3.8 Max) для распознавания сомнительных регионов. Токен: OPENCODE_API_KEY",
120
-        ),
121
-    ] = False,
122
-) -> None:
123
-    """OCR → PostProcess → Markdown. Принимает файл или каталог изображений."""
124
-    ocr_fn, ocr_batch_fn, engine_name = _get_ocr_engine(ocr_engine)
125
-
126
-    if input.is_dir():
127
-        image_paths = sorted(
128
-            p for p in input.iterdir()
129
-            if p.suffix.lower() in IMG_EXTENSIONS and p.is_file()
130
-        )
131
-        if not image_paths:
132
-            raise typer.BadParameter(f"Нет изображений в каталоге: {input}")
133
-
134
-        out = output_dir.resolve() if output_dir else input.resolve()
135
-        logger.info("Пакетная обработка %d изображений через %s", len(image_paths), engine_name)
136
-
137
-        total_start = time.time()
138
-
139
-        if ocr_batch_fn and ocr_engine == "surya":
140
-            results = ocr_batch_fn([str(p) for p in image_paths])
141
-            for img_path, page in zip(image_paths, results):
142
-                logger.info("%s: %d блоков", img_path.name, len(page.blocks))
143
-                fix_ocr_errors(page.blocks)
144
-                generate_markdown(page.blocks, out / (img_path.stem + MD_EXTENSION))
145
-        else:
146
-            for img_path in image_paths:
147
-                logger.info("%s...", img_path.name)
148
-                _process_single(ocr_fn, img_path, lang, out, use_llm, use_vlm)
149
-
150
-        total_dt = time.time() - total_start
151
-        avg_dt = total_dt / len(image_paths)
152
-        logger.info(
153
-            "Готово: %d изображений, всего %.1fs, среднее %.1fs/изобр",
154
-            len(image_paths), total_dt, avg_dt,
155
-        )
156
-    else:
157
-        out = output_dir.resolve() if output_dir else input.resolve().parent
158
-        _process_single(ocr_fn, input, lang, out, use_llm, use_vlm)
159
-
160
-
161
-def main() -> None:
162
-    run_main(app)
163
-
164
-
165
-if __name__ == "__main__":
166
-    main()

+ 146
- 0
src/latex/json_to_latex.py Просмотреть файл

@@ -0,0 +1,146 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+from pathlib import Path
5
+
6
+from bs4 import BeautifulSoup
7
+
8
+LATEX_PREAMBLE = r"""\documentclass[12pt,a4paper]{article}
9
+\usepackage[T2A]{fontenc}
10
+\usepackage[utf8]{inputenc}
11
+\usepackage[russian,english]{babel}
12
+\usepackage{amsmath,amssymb}
13
+\usepackage{geometry}
14
+\usepackage{booktabs}
15
+\geometry{margin=2cm}
16
+\begin{document}
17
+"""
18
+
19
+
20
+def _detect_format(data: dict) -> str:
21
+    if "blocks" in data and isinstance(data["blocks"], list):
22
+        first = data["blocks"][0] if data["blocks"] else {}
23
+        if "html" in first:
24
+            return "surya"
25
+    if "parsing_res_list" in data:
26
+        return "paddle"
27
+    raise ValueError("Неизвестный формат JSON: ожидается PaddleOCR или Surya")
28
+
29
+
30
+def _escape(text: str) -> str:
31
+    for char, repl in [("\\", "\\textbackslash{}"), ("&", "\\&"), ("%", "\\%"),
32
+                       ("$", "\\$"), ("#", "\\#"), ("_", "\\_"),
33
+                       ("{", "\\{"), ("}", "\\}"), ("~", "\\textasciitilde{}"),
34
+                       ("^", "\\^{}"), ("<", "\\textless{}"), (">", "\\textgreater{}")]:
35
+        text = text.replace(char, repl)
36
+    return text
37
+
38
+
39
+def _paddle_to_body(data: dict) -> str:
40
+    lines: list[str] = []
41
+    for item in data.get("parsing_res_list", []):
42
+        content = item.get("block_content", "").strip()
43
+        if not content:
44
+            continue
45
+        label = item.get("block_label", "text")
46
+
47
+        if label == "formula":
48
+            lines.append(f"\\[\n{content}\n\\]\n")
49
+        elif label in ("paragraph_title", "title", "section_header"):
50
+            lines.append(f"\\section*{{{_escape(content)}}}")
51
+        elif label == "text":
52
+            lines.append(f"\n{_escape(content)}\n")
53
+        else:
54
+            lines.append(f"\n{_escape(content)}\n")
55
+    return "\n".join(lines)
56
+
57
+
58
+def _surya_to_body(data: dict) -> str:
59
+    lines: list[str] = []
60
+    for b in data.get("blocks", []):
61
+        label = b.get("label", "")
62
+        html = b.get("html", "").strip()
63
+        if not html:
64
+            continue
65
+
66
+        if label == "Equation":
67
+            clean = html.replace('<math display="block">', "").replace("</math>", "").strip()
68
+            lines.append(f"\\[\n{clean}\n\\]\n")
69
+            continue
70
+
71
+        soup = BeautifulSoup(html, "html.parser")
72
+
73
+        if soup.find("table"):
74
+            lines.append(_table_to_latex(soup))
75
+            lines.append("")
76
+            continue
77
+
78
+        for math_tag in soup.find_all("math"):
79
+            display = math_tag.get("display", "") == "block"
80
+            latex = math_tag.get_text().strip()
81
+            math_tag.replace_with(f"\\[\n{latex}\n\\]" if display else f"${latex}$")
82
+
83
+        text = soup.get_text("\n").strip()
84
+        if not text:
85
+            continue
86
+
87
+        if label in ("SectionHeader",):
88
+            lines.append(f"\\section*{{{text}}}")
89
+        elif label == "PageHeader":
90
+            lines.append(f"{text}")
91
+        else:
92
+            lines.append(f"\n{text}\n")
93
+    return "\n".join(lines)
94
+
95
+
96
+def _table_to_latex(soup: BeautifulSoup) -> str:
97
+    rows = soup.find_all("tr")
98
+    if not rows:
99
+        return ""
100
+    ncols = max(len(row.find_all(["td", "th"])) for row in rows)
101
+    lines = [f"\\begin{{tabular}}{{|{'c|' * ncols}}}", "\\hline"]
102
+    for row in rows:
103
+        cells = [cell.get_text().strip() for cell in row.find_all(["td", "th"])]
104
+        lines.append(" & ".join(cells) + " \\\\")
105
+        lines.append("\\hline")
106
+    lines.append("\\end{tabular}")
107
+    return "\n".join(lines)
108
+
109
+
110
+def json_to_latex(json_path: str | Path) -> str:
111
+    data = json.loads(Path(json_path).read_text())
112
+    fmt = _detect_format(data)
113
+
114
+    if fmt == "paddle":
115
+        body = _paddle_to_body(data)
116
+    else:
117
+        body = _surya_to_body(data)
118
+
119
+    return LATEX_PREAMBLE + body + "\n\\end{document}\n"
120
+
121
+
122
+def multi_json_to_latex(json_dir: str | Path, output_path: str | Path | None = None) -> str:
123
+    json_dir = Path(json_dir)
124
+    pages: list[str] = []
125
+    for jp in sorted(json_dir.glob("[!.]*.json")):
126
+        if jp.stem == json_dir.name:
127
+            continue
128
+        try:
129
+            body = json_to_latex(jp)
130
+            start = body.find("\\begin{document}") + len("\\begin{document}")
131
+            end = body.find("\\end{document}")
132
+            pages.append(f"% {jp.stem}\n{body[start:end].strip()}\n\\newpage")
133
+        except (json.JSONDecodeError, KeyError, ValueError):
134
+            continue
135
+
136
+    if not pages:
137
+        return ""
138
+
139
+    first_path = min(json_dir.glob("[!.]*.json"), key=lambda p: p.stem)
140
+    first = json_to_latex(first_path)
141
+    preamble_end = first.find("\\begin{document}") + len("\\begin{document}")
142
+    full = first[:preamble_end] + "\n" + "\n".join(pages) + "\n\\end{document}\n"
143
+
144
+    if output_path:
145
+        Path(output_path).write_text(full, encoding="utf-8")
146
+    return full

+ 23
- 13
src/ocr/surya_engine.py Просмотреть файл

@@ -3,7 +3,6 @@ from __future__ import annotations
3 3
 import logging
4 4
 import os
5 5
 import shutil
6
-from collections.abc import Sequence
7 6
 from pathlib import Path
8 7
 from typing import TYPE_CHECKING
9 8
 
@@ -47,7 +46,7 @@ class SuryaEngine:
47 46
       - llama-server kept alive between calls (SURYA_INFERENCE_KEEP_ALIVE=true)
48 47
       - reduced context per slot (8192 vs 12288) saves memory
49 48
       - single parallel slot (no batch needed for single page)
50
-      - image pre-scaled to 1056px wide (≈96 DPI) 
49
+      - image pre-scaled to 1056px wide (≈96 DPI)
51 50
       - explicit thread count matching CPU cores
52 51
     """
53 52
 
@@ -71,12 +70,19 @@ class SuryaEngine:
71 70
         image = Image.open(image_path)
72 71
         return self._process_image(image)
73 72
 
74
-    def process_batch(self, image_paths: Sequence[str]) -> list[OcrPageResult]:
75
-        from PIL import Image
73
+    def _health_check(self) -> bool:
74
+        import requests
76 75
 
77
-        images = [_pre_scale(Image.open(p)) for p in image_paths]
78
-        results = self._predictor(images)
79
-        return [self._to_result(page, img) for page, img in zip(results, images)]
76
+        port = os.environ.get("SURYA_INFERENCE_PORT", "")
77
+        host = os.environ.get("SURYA_INFERENCE_HOST", "127.0.0.1")
78
+        if not port:
79
+            return True
80
+        try:
81
+            r = requests.get(f"http://{host}:{port}/health", timeout=10)
82
+            return r.status_code == 200
83
+        except requests.RequestException:
84
+            logger.warning("llama-server health check failed")
85
+            return False
80 86
 
81 87
     def _process_image(self, image: PILImage.Image) -> OcrPageResult:
82 88
         scaled = _pre_scale(image)
@@ -124,11 +130,15 @@ def surya_ocr_image(image_path: str) -> OcrPageResult:
124 130
     global _surya_engine
125 131
     if _surya_engine is None:
126 132
         _surya_engine = SuryaEngine()
127
-    return _surya_engine.process(image_path)
128 133
 
134
+    if not _surya_engine._health_check():
135
+        logger.warning("llama-server не отвечает, перезапуск...")
136
+        _surya_engine = SuryaEngine()  # пересоздать — заново spawn сервер
137
+        if not _surya_engine._health_check():
138
+            raise RuntimeError(
139
+                "llama-server недоступен после перезапуска. "
140
+                "Проверьте: `ps aux | grep llama-server`. "
141
+                "Попробуйте: `llama-server --version`"
142
+            )
129 143
 
130
-def surya_ocr_batch(image_paths: Sequence[str]) -> list[OcrPageResult]:
131
-    global _surya_engine
132
-    if _surya_engine is None:
133
-        _surya_engine = SuryaEngine()
134
-    return _surya_engine.process_batch(image_paths)
144
+    return _surya_engine.process(image_path)

+ 2
- 2
src/postprocess/llm_corrector.py Просмотреть файл

@@ -37,8 +37,8 @@ class LlmCorrector:
37 37
 _llm_corrector: LlmCorrector | None = None
38 38
 
39 39
 
40
-def llm_correct_block(block: ParsedBlock) -> ParsedBlock:
40
+def llm_correct_block(block: ParsedBlock) -> None:
41 41
     global _llm_corrector
42 42
     if _llm_corrector is None:
43 43
         _llm_corrector = LlmCorrector()
44
-    return _llm_corrector(block)
44
+    _llm_corrector(block)

+ 9
- 9
src/postprocess/vlm_corrector.py Просмотреть файл

@@ -39,16 +39,16 @@ def _image_to_base64(image: Any) -> str:
39 39
     return base64.b64encode(buffer).decode("utf-8")
40 40
 
41 41
 
42
-def vlm_correct_block(block: ParsedBlock, image_path: str, model: str = DEFAULT_MODEL) -> ParsedBlock:
42
+def vlm_correct_block(block: ParsedBlock, image_path: str, model: str = DEFAULT_MODEL) -> None:
43 43
     if block.label == "formula":
44
-        return block
44
+        return
45 45
     if not _get_api_key():
46 46
         logger.warning("VLM: %s не задан, пропускаем", API_KEY_ENV)
47
-        return block
47
+        return
48 48
 
49 49
     image = cv2.imread(image_path)
50 50
     if image is None:
51
-        return block
51
+        return
52 52
 
53 53
     h, w = image.shape[:2]
54 54
     x1, y1, x2, y2 = block.bbox
@@ -57,7 +57,7 @@ def vlm_correct_block(block: ParsedBlock, image_path: str, model: str = DEFAULT_
57 57
     x2 = min(w, x2 + 5)
58 58
     y2 = min(h, y2 + 5)
59 59
     if x1 >= x2 or y1 >= y2:
60
-        return block
60
+        return
61 61
 
62 62
     crop = image[y1:y2, x1:x2]
63 63
     crop_h, crop_w = crop.shape[:2]
@@ -112,16 +112,16 @@ def vlm_correct_block(block: ParsedBlock, image_path: str, model: str = DEFAULT_
112 112
                 logger.info("VLM: результат (%d символов): %s", len(text), text[:120])
113 113
             else:
114 114
                 logger.info("VLM: пустой или короткий ответ (%d символов)", len(text) if text else 0)
115
-            return block
115
+            return
116 116
         except requests.HTTPError as e:
117 117
             if e.response is not None and e.response.status_code == 500 and attempt == 1:
118 118
                 logger.info("VLM: HTTP 500, ожидание 5с и повтор...")
119 119
                 _time.sleep(5)
120 120
                 continue
121 121
             logger.warning("VLM: ошибка — %s", e)
122
-            return block
122
+            return
123 123
         except (requests.RequestException, KeyError, IndexError) as e:
124 124
             logger.warning("VLM: ошибка — %s", e)
125
-            return block
125
+            return
126 126
 
127
-    return block
127
+    return

+ 6
- 0
src/split/slicer.py Просмотреть файл

@@ -128,6 +128,9 @@ def is_empty(image: np.ndarray) -> bool:
128 128
 
129 129
 
130 130
 def _binarize(image: np.ndarray) -> np.ndarray:
131
+    """Бинаризация: серый → Гаусс-блюр → Otsu-порог → морф.закрытие.
132
+    При слишком низком пороге Otsu (<50) — адаптивный порог (Gaussian, окно 31).
133
+    Морфологическое закрытие (3×3) склеивает фрагменты букв в непрерывные регионы."""
131 134
     gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
132 135
     blurred = cv2.GaussianBlur(gray, (3, 3), 0)
133 136
     otsu_th, binary = cv2.threshold(
@@ -143,6 +146,9 @@ def _binarize(image: np.ndarray) -> np.ndarray:
143 146
 
144 147
 
145 148
 def detect_grid(image: np.ndarray) -> tuple[int, int]:
149
+    """Автоопределение сетки: бинаризация → проекция плотности → дилатация → подсчёт полос.
150
+    Дилатация (ядро = max(5, длина/40)) сливает строки текста в непрерывные блоки,
151
+    но не перекрывает межстраничные пробелы (они шире)."""
146 152
     binary = _binarize(image)
147 153
 
148 154
     cols = _count_bands(binary, axis=0)

+ 119
- 124
stage2_results.md Просмотреть файл

@@ -20,21 +20,21 @@
20 20
 **Результат:** 22 структурированных блока с layout-разметкой.
21 21
 - Layout detection: ✅ `[number]`, `[formula]`, `[text]`, `[paragraph_title]`
22 22
 - Display-формулы: ✅ `\mathrm{C Rec}_{\mathrm{it}}=\sum...`
23
-- Таблицы: ✅ (PP-TableMagic)
24
-- Русский текст: ⚠️ ~85% (eslav_PP-OCRv5_mobile_rec — мобильная модель)
25
-- **Вывод:** структура отлично, текст — приемлемо
23
+- Таблицы: ✅ PP-TableMagic с bbox ячеек
24
+- Русский текст: ⚠️ ~85% (eslav_PP-OCRv5_mobile_rec)
25
+- **Вывод:** структура отлично, текст — терпимо
26 26
 
27
-### 3. Замена модели на cyrillic_PP-OCRv5_mobile_rec
27
+### 3. Замена на cyrillic_PP-OCRv5_mobile_rec
28 28
 **Результат:** разница ~1-2%, несущественна.
29 29
 - eslav: `LGD_ехtг;` (кириллица г)
30 30
 - cyrillic: `LGD_ехtr;` (латиница r) — чуть лучше
31
-- **Вывод:** обе мобильные модели с одинаковыми ограничениями
31
+- **Вывод:** обе мобильные модели, серверной русской не существует
32 32
 
33 33
 ### 4. PP-OCRv5_server_rec / PP-OCRv6
34
-**Результат:** ❌ не поддерживают русский язык.
34
+**Результат:** ❌ не поддерживают русский.
35 35
 - Server model: только CN/JP/EN
36
-- PP-OCRv6: 52 латинских языка, русского нет
37
-- **Вывод:** серверной русской модели в PaddleOCR не существует
36
+- PP-OCRv6: 52 латинских языка
37
+- **Вывод:** русский — только mobile-версии
38 38
 
39 39
 ### 5. Локальный LLM-корректор (Qwen2.5-7B GGUF)
40 40
 **Результат:** +25% читаемости текста.
@@ -46,172 +46,167 @@
46 46
 - **Вывод:** эффективен для пост-обработки
47 47
 
48 48
 ### 6. VLM-корректор (Qwen3.8 Max через OpenCode API)
49
-**Результат:** полное восстановление текста + автоопределение inline-формул.
49
+**Результат:** полное восстановление текста + авто inline-формулы.
50 50
 ```
51 51
 До:   ΣДсе, - дисконтированная сумма возвратов
52
-После: $\sum_t DCF_{i,t}$ — дисконтированная сумма возвратов, получаемых на t-ом периоде
52
+После: $\sum_t DCF_{i,t}$ — дисконтированная сумма возвратов
53 53
 ```
54
-- ~10s/блок, Anthropic API через `zen/go/v1/messages`
55
-- **Вывод:** лучшее качество, но требует API-доступа
54
+- ~10s/блок, Anthropic API `zen/go/v1/messages`
55
+- **Вывод:** лучшее качество, требует API-доступа
56 56
 
57 57
 ### 7. Docling (IBM Research)
58 58
 **Результат:** ❌ не поддерживает PaddleOCR как OCR-backend.
59 59
 - Поддерживает: EasyOCR, Tesseract, RapidOCR, Nemotron
60
-- Генерирует HTML/Markdown из коробки
61 60
 - **Вывод:** несовместим с нашим пайплайном
62 61
 
63
-### 8. PaddleOCR-VL (VLM-подход, ERNIE-4.5-0.3B)
64
-**Результат:** модель загружена (1.79 GB), но ~15 мин/стр на CPU.
65
-- Одна VLM вместо ансамбля: layout → распознавание единой моделью
66
-- Теоретически решает проблему русского (языковая модель понимает контекст)
62
+### 8. PaddleOCR-VL (ERNIE-4.5-0.3B)
63
+**Результат:** модель загружена (1.79 GB), ~15 мин/стр на CPU.
64
+- Одна VLM вместо ансамбля
65
+- Теоретически решает проблему русского (LM понимает контекст)
67 66
 - **Вывод:** правильный подход при GPU, нереализуем на CPU
68 67
 
69
-### 9. Surya 2 (Datalab, 650M VLM)
70
-**Результат:** лучший русский OCR из коробки.
68
+### 9. Surya 2 (Datalab, 650M VLM)
69
+**Результат:** лучший русский OCR из коробки — но медленно на CPU.
71 70
 - 95%+ точность русского текста без коррекции
72
-- Автоопределение inline-формул: `<math>MRec_{it}</math>`
73
-- ~430s/стр на CPU (vs 140s у PaddleOCR, но качество несравнимо)
74
-- Вывод Markdown с `$$...$$` формулами
75
-- **Вывод:** лучший выбор при достаточных ресурсах
71
+- Авто inline-формулы: `<math>MRec_{it}</math>` → `$MRec_{it}$`
72
+- ~300s/стр после CPU-оптимизаций (vs 140s PaddleOCR)
73
+- **Слабость:** таблицы требуют много токенов → таймауты на CPU
74
+- **Вывод:** лучший выбор по качеству, медленнее на CPU
75
+
76
+### 10. VLM full-page (Qwen3.8 Max) для таблиц
77
+**Результат:** 118s на страницу с таблицей, качество отличное.
78
+- Таблица → pipe-Markdown: `| col1 | col2 |`
79
+- Формулы → `$...$`
80
+- Структура заголовков сохранена
81
+- **Вывод:** для табличных страниц — лучший вариант
76 82
 
77 83
 ---
78 84
 
79 85
 ## Итоговая архитектура
80 86
 
81 87
 ```
82
-                        Входное изображение (JPEG/PNG/TIFF)
83
-                                    │
84
-                      ┌─────────────┴─────────────┐
85
-                      │    src/image_split.py      │
86
-                      │  --slice / --slice-auto    │
87
-                      │  --pre-rotate, --border    │
88
-                      │  --post-crop (default ON)  │
89
-                      └─────────────┬─────────────┘
90
-                                    │
91
-                             JPEG страницы
92
-                                    │
93
-                      ┌─────────────┴─────────────┐
94
-                      │  src/image_to_latex.py     │
95
-                      │  --ocr-engine paddle/surya │
96
-                      │  --llm, --vlm (optional)   │
97
-                      └─────────────┬─────────────┘
98
-                                    │
99
-              ┌─────────────────────┼─────────────────────┐
100
-              ▼                     ▼                     ▼
101
-    ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
102
-    │  src/ocr/        │  │ src/postprocess/ │  │ src/postprocess/ │
103
-    │  paddle_engine   │  │ fixups.py        │  │ llm_corrector    │
104
-    │  surya_engine    │  │ vlm_corrector    │  │ corrector.py     │
105
-    └────────┬────────┘  └────────┬────────┘  └────────┬────────┘
106
-             │                    │                     │
107
-             └────────────────────┼─────────────────────┘
108
-                                  │
109
-                         ParsedBlock[]
110
-                                  │
111
-                    ┌─────────────┴─────────────┐
112
-                    │  src/markdown/             │
113
-                    │  generator.py              │
114
-                    │  Валидация $, {}, \left   │
115
-                    └─────────────┬─────────────┘
116
-                                  │
117
-              ┌───────────────────┼───────────────────┐
118
-              ▼                   ▼                   ▼
119
-         .json (raw)          .md              .tex + .html
120
-     (PP-StructureV3      (Markdown)      (LaTeX + MathJax)
121
-      полный дамп)
88
+Входное изображение (JPEG/PNG/TIFF)
89
+    │
90
+    ▼
91
+src/image_split.py          ← Stage 1: разрезание
92
+    │ --slice / --slice-auto
93
+    │ --border, --post-crop
94
+    │
95
+    ▼
96
+src/image_ocr.py            ← Stage 2: OCR + Markdown
97
+    │ --ocr-engine paddle|surya
98
+    │ --llm, --vlm
99
+    │ --force-ocr, --pause
100
+    │ --result-one-document (ON по умолчанию)
101
+    │
102
+    ├─ PaddleEngine / SuryaEngine   (OCR)
103
+    ├─ fix_ocr_errors               (VaR, V^2)
104
+    ├─ LlmCorrector                 (Qwen2.5-7B, local)
105
+    ├─ VlmCorrector                 (Qwen3.8 Max, API)
106
+    │
107
+    ▼
108
+.md + .json (per page) + merged.md
122 109
 ```
123 110
 
111
+### По умолчанию включено
112
+
113
+| Функция | Статус |
114
+|---------|:------:|
115
+| `--resume` (пропуск JSON) | ON |
116
+| `--result-one-document` | ON |
117
+| `--pause 5` | ON |
118
+| `--post-crop` (Stage 1) | ON |
119
+
124 120
 ### Модули
125 121
 
126 122
 ```
127 123
 src/
128
-  cli_utils.py           — run_main() общий для CLI
129
-  image_split.py         — CLI разрезания (Stage 1)
130
-  image_to_latex.py      — CLI OCR (Stage 2)
131
-  split/
132
-    slicer.py            — разрезание, поворот, рамка, post-crop
124
+  image_split.py         — CLI Stage 1
125
+  image_ocr.py           — CLI Stage 2
126
+  cli_utils.py           — run_main()
127
+  env.py                 — загрузка .env
128
+  split/slicer.py        — разрезание, post-crop, border
133 129
   ocr/
134 130
     paddle_engine.py     — PP-StructureV3 + кастом YAML (eslav)
135
-    surya_engine.py      — Surya 2 VLM + llama.cpp
131
+    surya_engine.py      — Surya 2 + CPU-оптимизации + health_check
136 132
   postprocess/
137 133
     fixups.py            — VaR, V^2
138
-    corrector.py         — Corrector Protocol + apply_corrector()
139
-    llm_corrector.py     — Qwen2.5-7B локально (llama-cpp-python)
140
-    vlm_corrector.py     — Qwen3.8 Max через OpenCode API (Anthropic)
141
-  markdown/
142
-    generator.py         — Markdown + валидация LaTeX
134
+    corrector.py         — Corrector Protocol
135
+    llm_corrector.py     — Qwen2.5-7B локально
136
+    vlm_corrector.py     — Qwen3.8 Max API (Anthropic)
137
+  markdown/generator.py  — Markdown + валидация LaTeX
143 138
   latex/
144
-    tex2html.py          — .tex → .html + MathJax
139
+    json_to_latex.py     — Единый JSON→LaTeX (Paddle + Surya)
140
+    surya2html.py        — Surya JSON → HTML + MathJax
141
+    tex2html.py          — TeX → HTML
145 142
 ```
146 143
 
147
-### Модели (локально, ~4 GB)
144
+---
145
+
146
+## Оптимизации Surya 2 для CPU
147
+
148
+| Параметр | До | После | Эффект |
149
+|----------|:---:|:---:|--------|
150
+| KEEP_ALIVE | off | on | -23s повторный старт |
151
+| CTX per slot | 12288 | 8192 | -30% RAM |
152
+| Parallel slots | 8 | 1 | -80% слотов |
153
+| Pre-scale | 2552px | 1056px | -60% пикселей |
154
+| Threads | default | `-t 8` | стабильно |
155
+| **Итого** | **430s** | **300s** | **-30%** |
148 156
 
157
+### Health-check + retry
149 158
 ```
150
-~/.paddlex/official_models/          (PaddleOCR, ~2.5 GB)
151
-  PP-DocLayout_plus-L                layout detection
152
-  PP-OCRv5_server_det                text detection
153
-  eslav_PP-OCRv5_mobile_rec          RU/EN recognition
154
-  PP-OCRv5_server_rec                EN/CN recognition
155
-  SLANeXt_wired                      table structure
156
-  PP-FormulaNet_plus-L               formula recognition (728 MB)
157
-
158
-~/.cache/llama_models/               (LLM, ~4.4 GB)
159
-  Qwen2.5-7B-Instruct-Q4_K_M.gguf    локальный корректор
160
-
161
-HF Hub (Surya 2, ~1.5 GB)
162
-  datalab-to/surya-ocr-2-gguf        650M VLM
159
+llama-server не отвечает → logger.warning → перезапуск SuryaEngine
160
+→ повторный health_check → OK / RuntimeError с инструкцией
163 161
 ```
164 162
 
165 163
 ---
166 164
 
167 165
 ## Сравнение движков
168 166
 
169
-| Параметр | PaddleOCR (PP-StructV3) | Surya 2 |
170
-|----------|:---:|:---:|
171
-| Архитектура | Ансамбль 5 моделей | Один VLM (650M) |
172
-| Русский текст | ~85% | 95%+ |
173
-| Формулы | PP-FormulaNet (отлично) | VLM (отлично) |
174
-| Таблицы | PP-TableMagic + bbox | HTML `<table>` |
175
-| Inline-формулы | Не детектируются | `<math>` тэги |
176
-| Скорость CPU | ~140s | ~430s |
177
-| Размер моделей | ~2.5 GB | ~1.5 GB |
178
-| LLM-коррекция | Нужна для русского | Не нужна |
179
-| VLM-коррекция | Нужна для сложных мест | Не нужна |
167
+| Параметр | PaddleOCR | Surya 2 | VLM Qwen3.8 |
168
+|----------|:---:|:---:|:---:|
169
+| Архитектура | Ансамбль 5 моделей | Один VLM (650M) | Cloud VLM |
170
+| Русский текст | ~85% | 95%+ | 98%+ |
171
+| Формулы | PP-FormulaNet | VLM (отлично) | VLM (отлично) |
172
+| Таблицы | PP-TableMagic + bbox | ❌ CPU timeout | ✅ Pipe-Markdown |
173
+| Inline-формулы | Нет | `<math>` тэги | `$...$` |
174
+| Скорость CPU | ~140s | ~300s | ~120s (API) |
175
+| Офлайн | ✅ | ✅ | ❌ |
176
+| Размер моделей | ~2.5 GB | ~1.5 GB | 0 (cloud) |
180 177
 
181 178
 ---
182 179
 
183
-## Ключевые цифры
180
+## Ключевые цифры (на CPU: 8 ядер, 32 GB RAM)
184 181
 
185
-| Этап | PaddleOCR | Surya 2 |
186
-|------|:---:|:---:|
187
-| Image split + post-crop | 1-2s | 1-2s |
188
-| OCR | 140s | 430s |
189
-| LLM-корректор (Qwen2.5-7B) | ~20s/блок | не нужен |
190
-| VLM-корректор (Qwen3.8 Max) | ~10s/блок | не нужен |
191
-| Markdown генерация | <1s | <1s |
192
-| **Итого на страницу** | ~5 min | ~7 min |
182
+| Этап | PaddleOCR | Surya 2 | VLM |
183
+|------|:---:|:---:|:---:|
184
+| OCR (одна страница) | 140s | 300s | 120s |
185
+| LLM-корректор | +5 min | не нужен | не нужен |
186
+| VLM-корректор | +10s/блок | не нужен | — |
187
+| JSON + MD сохранение | <1s | <1s | <1s |
188
+| Merge в один документ | <1s | <1s | <1s |
189
+| **Итого на страницу** | ~5 min | ~5 min | ~2 min |
190
+| **12 страниц (batch)** | ~28 min* | ~60 min | ~24 min |
191
+
192
+\* PaddleOCR с `--pause 5`
193 193
 
194 194
 ---
195 195
 
196 196
 ## Неиспользованные подходы
197 197
 
198
-1. **EasyOCR** — WER 0.12 vs Paddle 0.056, хуже по метрикам
199
-2. **Tesseract** — нет поддержки GPU, хуже качество на русском
200
-3. **RapidOCR** — легче, но качество ниже PaddleOCR
201
-4. **OmniParser** — избыточен (YOLO + OCR), медленнее
202
-5. **Docling** — не поддерживает PaddleOCR как OCR-backend
203
-6. **Fine-tune модели** — требует GPU + размеченный датасет, не пробовали
198
+1. **EasyOCR** — WER 0.12 vs Paddle 0.056
199
+2. **Tesseract** — нет GPU, хуже на русском
200
+3. **RapidOCR** — легче, качество ниже
201
+4. **Fine-tune eslav** — требует GPU + датасет
204 202
 
205 203
 ---
206 204
 
207 205
 ## Выводы
208 206
 
209
-1. **PaddleOCR PP-StructureV3** — надёжный выбор для структуры документа и формул. Русский текст требует коррекции (LLM или VLM).
210
-
211
-2. **Surya 2** — лучший русский OCR из коробки, авто inline-формулы. Медленнее на CPU, но качество несравнимо выше.
212
-
213
-3. **Гибрид Surya 2 + PP-FormulaNet** — теоретически лучший вариант (Surya для текста, Paddle для формул), не реализован.
214
-
215
-4. **LLM/VLM коррекция** — эффективна для PaddleOCR, не нужна для Surya 2.
216
-
217
-5. **OpenCode Go API** — практичный способ подключить мощную VLM без локального GPU.
207
+1. **PP-StructureV3** — надёжен для структуры и формул, русский текст требует LLM/VLM коррекции
208
+2. **Surya 2** — лучший русский OCR из коробки, CPU-оптимизации дали -30% времени
209
+3. **VLM Qwen3.8 Max** — решает таблицы там, где Surya падает, при приемлемой скорости
210
+4. **Гибридный подход**: Surya для текста + VLM для таблиц = лучший баланс
211
+5. **LLM/VLM коррекция** эффективна для PaddleOCR, не нужна для Surya 2
212
+6. **CPU-ограничения** — основной фактор, GPU сделал бы Surya/PaddleOCR-VL идеальным выбором

Загрузка…
Отмена
Сохранить