Browse Source

.raw as non-changeable results of VLM processing, all postprocessing routines are moved to 3rd stagge generate_result; postprocessing upgraded

master
Evgeniy Ierusalimov 2 weeks ago
parent
commit
3a0d25126c

+ 1
- 0
.gitignore View File

@@ -16,3 +16,4 @@ __pycache__/
16 16
 .vscode/
17 17
 .DS_Store
18 18
 
19
+*.log

+ 107
- 73
README.md View File

@@ -22,28 +22,31 @@
22 22
 JPEG/TIFF/PNG (многостраничный скан)
23 23
24 24
25
-src/image_prepare.py        ← Stage 1: подготовка (разрезание, обрезка)
25
+src/image_prepare.py        ← Stage 1: подготовка (разрезание, unwarp, denoise)
26 26
     │ --slice / --slice-auto
27 27
     │ --pre-rotate, --border
28
-    │ --post-crop
28
+    │ --unwarp, --denoise, --post-crop
29 29
30 30
31
-JPEG страницы
31
+JPEG/PNG страницы
32 32
33 33
34
-src/image_ocr.py           ← Stage 2: OCR + Markdown
35
-    │ --ocr-engine paddle|surya
36
-    │ --input file.jpg         (одна страница)
37
-    │ --input pages/           (пакетный режим: все изображения в каталоге)
38
-    │ --llm, --vlm, --force-ocr
39
-    │ --pause 5 (по умолчанию)
40
-    │ --result-one-document    (по умолчанию ON, объединить в один .md)
41
-    ├─ PP-StructureV3 / Surya 2    (OCR)
42
-    ├─ fixups                      (VaR, V^2)
43
-    ├─ VLM corrector               (Qwen3.8 Max, API)
34
+src/image_ocr.py           ← Stage 2: OCR → сырой ответ
35
+    │ --ocr-engine paddle|surya|external-qwen3
36
+    │ --main-lang ru|en
37
+    │ --fix-by-external-qwen3
44 38
45 39
46
-.md + .json + .html
40
+.raw (сырой ответ VLM, неизменяемый)
41
+    │
42
+    ▼
43
+src/generate_result.py     ← Stage 3: постпроцессинг
44
+    │ фильтрация номеров страниц
45
+    │ восстановление таблиц между страницами
46
+    │ очистка формул (\text, \_)
47
+    │
48
+    ▼
49
+.json + .md + объединённый .md + .tex + .html
47 50
 ```
48 51
 
49 52
 ---
@@ -134,34 +137,44 @@ python -m src.image_prepare -i old_scan.jpg --denoise -s 2:2 -b 10
134 137
 | `--input` / `-i` | Файл изображения **или** каталог | required |
135 138
 | `--output-dir` / `-o` | Каталог вывода | рядом с `--input` |
136 139
 | `--ocr-engine` | **Обязательный:** `paddle`, `surya` или `external-qwen3` | — |
140
+| `--main-lang` | Основной язык: `ru` (eslav rec) или `en` (PP-OCRv6 rec) | `ru` |
137 141
 | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
138
-| `--main-lang` | Основной язык: `ru` (eslav) или `en` (PP-OCRv6 rec) | `ru` |
139 142
 | `--pause` | Пауза между изображениями (сек) | 5 |
140
-| `--no-result-one-document` | Не объединять `.md` в один документ | — |
141
-| `--no-post-crop` | Не обрезать по контенту (Stage 1) | — |
143
+
144
+Выход: `.raw`-файл с сырым ответом движка (write-once, не изменяется при постпроцессинге).
142 145
 
143 146
 Пример:
144 147
 ```bash
145 148
 # Одна страница — PaddleOCR
146 149
 python -m src.image_ocr -i page.jpg --ocr-engine paddle
147 150
 
148
-# Одна страница — Surya 2
149
-python -m src.image_ocr -i page.jpg --ocr-engine surya
151
+# Внешняя VLM как OCR-движок (Qwen3.8 Max) — лучший для русского
152
+python -m src.image_ocr -i pages/ --ocr-engine external-qwen3
150 153
 
151
-# Пакетный режим
154
+# Английский документ (PP-OCRv6 rec)
155
+python -m src.image_ocr -i page.jpg --ocr-engine paddle --main-lang en
156
+
157
+# Пакетный режим — Surya 2 (batch)
152 158
 python -m src.image_ocr -i ./pages/ --ocr-engine surya
159
+```
153 160
 
154
-# Внешняя VLM как OCR-движок (Qwen3.8 Max)
155
-python -m src.image_ocr -i page.jpg --ocr-engine external-qwen3
161
+### Stage 3: `src/generate_result.py`
156 162
 
157
-# Без объединения в один документ
158
-python -m src.image_ocr -i ./pages/ --ocr-engine surya --no-result-one-document
163
+Постпроцессинг `.raw` → `.json` + `.md` + `.tex` + `.html`.
159 164
 
160
-# С VLM-корректором
161
-python -m src.image_ocr -i page.jpg --ocr-engine paddle --fix-by-external-qwen3
165
+| Параметр | Описание | По умолчанию |
166
+|----------|----------|:---:|
167
+| `--input` / `-i` | Каталог с `.raw`-файлами | required |
168
+| `--skip-latex` | Не генерировать LaTeX и HTML | OFF |
162 169
 
163
-# Английский документ (PP-OCRv6 rec)
164
-python -m src.image_ocr -i page.jpg --ocr-engine paddle --main-lang en
170
+Что делает:
171
+1. **Фильтрация номеров страниц** — блоки вида `42`, `[73]` в конце страницы удаляются
172
+2. **Восстановление таблиц** — таблицы, разорванные между страницами, объединяются (по кол-ву колонок)
173
+3. **Очистка формул** — `\text{score\_beh}` → `score_beh` для читаемости LLM
174
+4. **Генерация** — объединённый `.md` + `.tex` + `.html` (MathJax)
175
+
176
+```bash
177
+python -m src.generate_result -i cbr2/
165 178
 ```
166 179
 
167 180
 <details>
@@ -195,10 +208,11 @@ python -m src.image_ocr -i page.jpg --ocr-engine paddle --main-lang en
195 208
 │                                                        работает с            │
196 209
 │                                                        --ocr-engine          │
197 210
 │                                                        external-qwen3        │
198
-│    --no-result-one-doc…                                Не объединять .md     │
199
-│                                                        результаты в один     │
200
-│                                                        документ              │
201
-│                                                        [default: True]       │
211
+│    --main-lang                   <str>                 Основной язык: ru     │
212
+│                                                        (eslav_PP-OCRv5_mobi… │
213
+│                                                        или en                │
214
+│                                                        (PP-OCRv6_medium_rec) │
215
+│                                                        [default: ru]         │
202 216
 │    --pause                       <int range>           Пауза между           │
203 217
 │                                  [0<=x<=600]           изображениями в       │
204 218
 │                                                        секундах (по          │
@@ -210,6 +224,26 @@ python -m src.image_ocr -i page.jpg --ocr-engine paddle --main-lang en
210 224
 ```
211 225
 </details>
212 226
 
227
+### Stage 3: `src/generate_result.py` — `--help`
228
+
229
+<details>
230
+<summary><code>--help</code></summary>
231
+
232
+```
233
+ Usage: python -m src.generate_result [OPTIONS]
234
+
235
+ Постобработка результатов OCR: фильтрация, таблицы, объединение, LaTeX+HTML.
236
+
237
+╭─ Options ────────────────────────────────────────────────────────────────────╮
238
+│ *  --input       -i      <path>  Путь к каталогу с .raw-файлами             │
239
+│                                  (результаты OCR)                           │
240
+│                                  [required]                                 │
241
+│    --skip-latex                  Не генерировать LaTeX и HTML               │
242
+│    --help                        Show this message and exit.                │
243
+╰──────────────────────────────────────────────────────────────────────────────╯
244
+```
245
+</details>
246
+
213 247
 ### Debug-лог
214 248
 
215 249
 Для отладки зависаний и падений — файл с полным логом всех этапов обработки.
@@ -242,50 +276,41 @@ cp .env.dist .env
242 276
 
243 277
 ## Результат
244 278
 
245
-CLI (`image_ocr.py`) генерирует два файла для каждого изображения:
279
+### Stage 2 (`image_ocr.py`) — сырой ответ
246 280
 
247 281
 | Файл | Формат | Описание |
248 282
 |------|--------|----------|
249
-| `page_01.md` | Markdown | Текст + `$$`-формулы + `##`-заголовки. **Готов к загрузке в LLM.** |
250
-| `page_01.json` | JSON | Полный дамп OCR: bbox, confidence, labels, formulas |
283
+| `page_01.raw` | JSON | Сырой ответ OCR-движка. **Неизменяемый** — пишется один раз, все постпроцессинги читают его |
251 284
 
252
-**При пакетной обработке** (каталог на входе) — дополнительно:
285
+### Stage 3 (`generate_result.py`) — итоговые файлы
253 286
 
254
-| Файл | Описание |
255
-|------|----------|
256
-| `<dirname>.md` | Объединённый многостраничный Markdown (по умолчанию) |
257
-| `<dirname>.tex` | LaTeX для всех страниц (через `json_to_latex.py`) |
258
-
259
-**Ручные утилиты:**
260
-
261
-| Файл | Утилита | Назначение |
262
-|------|---------|------------|
263
-| `.tex` | `src/latex/json_to_latex.py` | JSON (Paddle / Surya / Qwen) → LaTeX + HTML (автодетект) |
287
+| Файл | Формат | Описание |
288
+|------|--------|----------|
289
+| `page_01.json` | JSON | Очищенный результат (фильтр номеров страниц, объединённые таблицы) |
290
+| `page_01.md` | Markdown | Текст + `$$`-формулы + `##`-заголовки. **Готов к загрузке в LLM.** |
291
+| `<dirname>.md` | Markdown | Объединённый многостраничный документ |
292
+| `<dirname>.tex` | LaTeX | LaTeX для всех страниц |
293
+| `<dirname>.html` | HTML | HTML + MathJax для просмотра в браузере |
264 294
 
265 295
 ```bash
266
-# JSON → LaTeX (один файл, автоопределение формата)
267
-python -m src.latex.json_to_latex page.json
268
-
269
-# JSON → LaTeX + HTML (каталог — объединённый многостраничный)
270
-python -m src.latex.json_to_latex cbr_en_2/
271
-
272
-# Программный вызов
273
-python -c "from src.latex.json_to_latex import json_to_latex; \
274
-  open('page.tex','w').write(json_to_latex('page.json'))"
275
-
276
-python -c "from src.latex.json_to_latex import multi_json_to_latex; \
277
-  multi_json_to_latex('out/', 'out/document.tex')"
296
+# Полный цикл
297
+python -m src.image_prepare -i scan.jpg -s 3:3 -o pages/
298
+python -m src.image_ocr -i pages/ --ocr-engine external-qwen3
299
+python -m src.generate_result -i pages/
278 300
 ```
279 301
 
280 302
 ---
281 303
 
282 304
 ## Качество OCR
283 305
 
284
-| Движок | Русский текст | Формулы | Скорость (CPU) |
285
-|--------|:---:|:---:|:---:|
286
-| PaddleOCR (PP-StructV3) | ~85% | ✅ отлично | ~140s |
287
-| + VLM (Qwen3.8 Max) | ~98% | ✅✅ | +10s/блок |
288
-| Surya 2 | ~95% | ✅ отлично | ~300s |
306
+| Движок | Русский текст | Английский | Формулы | Скорость |
307
+|--------|:---:|:---:|:---:|:---:|
308
+| PaddleOCR `--main-lang ru` (eslav rec + v6 det) | ~85-90% | ~60% | ✅ PP-FormulaNet | ~100-160s CPU |
309
+| PaddleOCR `--main-lang en` (v6 rec) | — | ~75-80% | ✅ PP-FormulaNet | ~100-160s CPU |
310
+| Surya 2 | ~95% | ~95% | ✅ VLM | ~300s CPU |
311
+| External VLM (Qwen3.8 Max) | ~98% | ~98% | ✅✅ | ~60-120s API |
312
+
313
+**Детектор:** PP-OCRv6_medium_det (RepLKFPN) — +4.6% vs v5_server.
289 314
 
290 315
 ---
291 316
 
@@ -360,19 +385,28 @@ llama-server не указывает количество потоков — п
360 385
 
361 386
 ```
362 387
 src/
363
-  image_prepare.py      — CLI Stage 1
364
-  image_ocr.py          — CLI Stage 2
365
-  split/slicer.py        — разрезание, post-crop
388
+  models.py             — ParsedBlock, OcrPageResult
389
+  image_utils.py        — calc_scale_dims
390
+  env.py                — загрузка .env
391
+  cli_utils.py          — run_main()
392
+  image_prepare.py      — CLI Stage 1 (разрезание, unwarp, denoise)
393
+  image_ocr.py          — CLI Stage 2 (OCR → .raw)
394
+  generate_result.py    — CLI Stage 3 (постпроцессинг → .json/.md/.tex/.html)
395
+  split/slicer.py        — разрезание, post-crop, border, unwarp, denoise
366 396
   ocr/
367
-    paddle_engine.py     — PP-StructureV3
368
-    surya_engine.py      — Surya 2
397
+    paddle_engine.py     — PP-StructureV3 + v6-det + ru/en rec
398
+    surya_engine.py      — Surya 2 + batch + CPU-оптимизации
399
+    external_vlm_engine.py — Qwen3.8 Max full-page OCR
369 400
   postprocess/
370
-    fixups.py            — OCR-ошибки (VaR)
371
-    corrector.py         — Corrector protocol
372
-    vlm_corrector.py     — Qwen3.8 Max (API)
373
-  markdown/generator.py  — Markdown + валидация
401
+    fixups.py            — OCR-ошибки (VaR, V^2)
402
+    corrector.py         — apply_corrector (Callable)
403
+    vlm_corrector.py     — Qwen3.8 Max per-block
404
+    qwen_client.py       — общий Qwen API-клиент
405
+  markdown/
406
+    tables.py            — общие функции таблиц (parse, detect, ncols)
407
+    generator.py         — Markdown + валидация LaTeX
374 408
   latex/
375
-    json_to_latex.py     — Единый JSON→LaTeX+HTML (Paddle + Surya + Qwen)
409
+    json_to_latex.py     — JSON→LaTeX+HTML (Paddle + Surya + Qwen)
376 410
 ```
377 411
 
378 412
 ---

+ 270
- 0
src/generate_result.py View File

@@ -0,0 +1,270 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+import logging
5
+import re
6
+from pathlib import Path
7
+from typing import Annotated
8
+
9
+import typer
10
+
11
+from src.cli_utils import run_main
12
+from src.markdown.generator import generate_markdown
13
+from src.markdown.tables import (
14
+    MD_TABLE_SEP,
15
+    PAGE_NUM,
16
+    table_ncols,
17
+)
18
+from src.models import ParsedBlock
19
+
20
+logging.basicConfig(
21
+    level=logging.INFO,
22
+    format="%(levelname)-8s %(message)s",
23
+)
24
+logger = logging.getLogger(__name__)
25
+app = typer.Typer(add_completion=False, no_args_is_help=True)
26
+
27
+_TABLE_ROW: re.Pattern = re.compile(r"^\|.+\|$")
28
+_UNESCAPE_TEXT: re.Pattern = re.compile(r"\\text\{([^}]+)\}")
29
+
30
+
31
+def _clean_formula(text: str) -> str:
32
+    def _replace(m: re.Match) -> str:
33
+        inner = m.group(1)
34
+        inner = inner.replace("\\_", "_").replace("\\ ", " ")
35
+        return inner
36
+    return _UNESCAPE_TEXT.sub(_replace, text)
37
+
38
+
39
+def _is_table_block(block: dict) -> bool:
40
+    content = (block.get("content", "") or "").strip()
41
+    if not content or "|" not in content:
42
+        return False
43
+    lines = content.splitlines()
44
+    data = [l for l in lines if not MD_TABLE_SEP.match(l)]
45
+    return len(data) >= 1 and all(_TABLE_ROW.match(line) for line in lines)
46
+
47
+
48
+def _table_ncols(block: dict) -> int:
49
+    return table_ncols(block.get("content", "") or "")
50
+
51
+
52
+def _table_data_rows(block: dict) -> str | None:
53
+    """Возвращает все строки данных (без заголовка и разделителя)."""
54
+    lines = (block.get("content", "") or "").strip().splitlines()
55
+    data = [l for l in lines if not MD_TABLE_SEP.match(l)]
56
+    if not data:
57
+        return None
58
+    first_cells = [c.strip() for c in data[0].split("|")[1:-1]]
59
+    is_header = _row_num(data[0]) is None and (
60
+        not any(first_cells)
61
+        or any(_row_num(l) is not None for l in data)
62
+    )
63
+    if len(data) >= 2 and is_header:
64
+        return "\n".join(data[1:])
65
+    return "\n".join(data)
66
+
67
+
68
+def _row_num(line: str) -> int | None:
69
+    try:
70
+        return int(line.strip().split("|")[1].strip())
71
+    except (ValueError, IndexError):
72
+        return None
73
+
74
+
75
+def _last_table_block(blocks: list[dict]) -> tuple[dict | None, int]:
76
+    for i in range(len(blocks) - 1, -1, -1):
77
+        if _is_table_block(blocks[i]):
78
+            return blocks[i], i
79
+    return None, -1
80
+
81
+
82
+def _merge_split_tables(pages: list[dict], names: list[str]) -> int:
83
+    """Объединяет split tables, охватывающие несколько соседних страниц (in-place)."""
84
+    merged_count = 0
85
+    i = 0
86
+    while i < len(pages) - 1:
87
+        blocks_a = pages[i].get("blocks", pages[i].get("parsing_res_list", []))
88
+        last_a, _ = _last_table_block(blocks_a)
89
+        if last_a is None:
90
+            i += 1
91
+            continue
92
+
93
+        ncols_a = _table_ncols(last_a)
94
+        j = i + 1
95
+        while j < len(pages):
96
+            blocks_b = pages[j].get("blocks", pages[j].get("parsing_res_list", []))
97
+            first_b = blocks_b[0] if blocks_b and _is_table_block(blocks_b[0]) else None
98
+            if first_b is None or _table_ncols(first_b) != ncols_a:
99
+                break
100
+
101
+            tail_rows = _table_data_rows(first_b)
102
+            if tail_rows is None:
103
+                break
104
+
105
+            rows_a = (last_a.get("content", "") or "").strip().splitlines()
106
+            data_a_rows = [l for l in rows_a if not MD_TABLE_SEP.match(l)]
107
+            tail_lines = tail_rows.splitlines()
108
+            if data_a_rows and tail_lines:
109
+                nums_a = {_row_num(l) for l in data_a_rows}
110
+                nums_a.discard(None)
111
+                tail_nums = {_row_num(l) for l in tail_lines}
112
+                tail_nums.discard(None)
113
+                if nums_a and tail_nums and min(tail_nums) > max(nums_a) + 1:
114
+                    break
115
+
116
+            last_a["content"] = (last_a.get("content", "") or "").rstrip() + "\n" + tail_rows
117
+            del blocks_b[0]
118
+            merged_count += 1
119
+            logger.info("Таблицы объединены: %s + %s (%d колонок)", names[i], names[j], ncols_a)
120
+            j += 1
121
+
122
+        i = max(i + 1, j - 1)
123
+
124
+    return merged_count
125
+
126
+
127
+def _filter_page_numbers(blocks: list[dict]) -> list[dict]:
128
+    dropped = [b for b in blocks if b.get("label") == "text" and PAGE_NUM.match((b.get("content", "") or "").strip())]
129
+    if dropped:
130
+        nums = ", ".join((b["content"] or "").strip() for b in dropped)
131
+        return [b for b in blocks if b not in dropped], nums
132
+    return blocks, ""
133
+
134
+
135
+def _process_raw(raw_path: Path) -> dict | None:
136
+    """Читает .raw, фильтрует номера страниц. Возвращает очищенные данные (не пишет)."""
137
+    data = json.loads(raw_path.read_text())
138
+    blocks = data.get("blocks", data.get("parsing_res_list", []))
139
+    if not blocks:
140
+        return None
141
+
142
+    clean, dropped_nums = _filter_page_numbers(blocks)
143
+    if dropped_nums:
144
+        logger.info("%s: убраны номера страниц [%s]", raw_path.name, dropped_nums)
145
+
146
+    if "blocks" in data:
147
+        data["blocks"] = clean
148
+    else:
149
+        data["parsing_res_list"] = clean
150
+    return data
151
+
152
+
153
+def _html_to_text(content: str) -> str:
154
+    if "<" not in content:
155
+        return content
156
+    from bs4 import BeautifulSoup
157
+
158
+    soup = BeautifulSoup(content, "html.parser")
159
+    for math_tag in soup.find_all("math"):
160
+        display = math_tag.get("display", "") == "block"
161
+        latex = math_tag.get_text().strip()
162
+        math_tag.replace_with(f"$$\n{latex}\n$$" if display else f"${latex}$")
163
+    return soup.get_text("\n").strip()
164
+
165
+
166
+def _save_output(data: dict, raw_path: Path) -> None:
167
+    """Сохраняет .json и .md из очищенных данных."""
168
+    blocks = data.get("blocks", data.get("parsing_res_list", []))
169
+    for b in blocks:
170
+        key = "content" if "content" in b else "block_content"
171
+        b[key] = _html_to_text(_clean_formula(b.get(key, "")))
172
+    json_path = raw_path.with_suffix(".json")
173
+    json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
174
+
175
+    parsed = [ParsedBlock(
176
+        label=b.get("label", b.get("block_label", "text")),
177
+        content=b.get(key, ""),
178
+        bbox=tuple(b.get("bbox", b.get("block_bbox", [0, 0, 0, 0]))[:4]),
179
+    ) for b in blocks]
180
+    generate_markdown(parsed, json_path.with_suffix(".md"))
181
+
182
+
183
+def _generate_latex_html(json_dir: Path) -> None:
184
+    from src.latex.json_to_latex import _generate_all as gen
185
+    gen(json_dir)
186
+
187
+
188
+def _merge_md(json_dir: Path) -> None:
189
+    parts: list[str] = []
190
+    for md_path in sorted(json_dir.glob("*.md")):
191
+        if md_path.stem == json_dir.name:
192
+            continue
193
+        content = md_path.read_text(encoding="utf-8")
194
+        parts.append(f"<!-- page: {md_path.stem} -->\n\n{content}")
195
+    if parts:
196
+        merged = "# Merged document\n\n" + "\n\n---\n\n".join(parts)
197
+        out = json_dir / f"{json_dir.name}.md"
198
+        out.write_text(merged, encoding="utf-8")
199
+        logger.info("Объединённый документ: %s", out)
200
+
201
+
202
+@app.command()
203
+def generate_result(
204
+    input: Annotated[
205
+        Path,
206
+        typer.Option(
207
+            "--input",
208
+            "-i",
209
+            exists=True,
210
+            file_okay=True,
211
+            dir_okay=True,
212
+            readable=True,
213
+            help="Путь к каталогу с .raw-файлами (результаты OCR)",
214
+        ),
215
+    ],
216
+    skip_latex: Annotated[
217
+        bool,
218
+        typer.Option("--skip-latex", help="Не генерировать LaTeX и HTML"),
219
+    ] = False,
220
+) -> None:
221
+    """Постобработка результатов OCR: фильтрация, таблицы, объединение, LaTeX+HTML."""
222
+    if input.is_dir():
223
+        raw_files = sorted(input.glob("[!.]*.raw"))
224
+        if not raw_files:
225
+            raise typer.BadParameter(f"Нет .raw-файлов в каталоге: {input}")
226
+
227
+        logger.info("Обработка %d .raw-файлов...", len(raw_files))
228
+        pages: list[dict] = []
229
+        names: list[str] = []
230
+        active_raw: list[Path] = []
231
+        for rp in raw_files:
232
+            if rp.stem == input.name:
233
+                continue
234
+            data = _process_raw(rp)
235
+            active_raw.append(rp)
236
+            if data is not None:
237
+                pages.append(data)
238
+            else:
239
+                pages.append({})
240
+            names.append(rp.stem)
241
+
242
+        merged = _merge_split_tables(pages, names)
243
+        if merged:
244
+            logger.info("Всего объединено таблиц между страницами: %d", merged)
245
+
246
+        for rp, data in zip(active_raw, pages):
247
+            if not data.get("blocks") and not data.get("parsing_res_list"):
248
+                for suffix in (".json", ".md"):
249
+                    stale = rp.with_suffix(suffix)
250
+                    if stale.exists():
251
+                        stale.unlink()
252
+                continue
253
+            _save_output(data, rp)
254
+
255
+        _merge_md(input)
256
+
257
+        if not skip_latex:
258
+            _generate_latex_html(input)
259
+    else:
260
+        data = _process_raw(input)
261
+        if data:
262
+            _save_output(data, input)
263
+
264
+
265
+def main() -> None:
266
+    run_main(app)
267
+
268
+
269
+if __name__ == "__main__":
270
+    main()

+ 25
- 40
src/image_ocr.py View File

@@ -12,7 +12,6 @@ import typer
12 12
 
13 13
 from src.cli_utils import run_main
14 14
 from src.env import load_env
15
-from src.markdown.generator import generate_markdown
16 15
 from src.ocr.paddle_engine import ocr_image
17 16
 from src.postprocess.fixups import fix_ocr_errors
18 17
 
@@ -31,10 +30,11 @@ for _mod in ("paddle", "paddlex", "paddleocr", "modelscope", "huggingface_hub",
31 30
 # Настройка логирования
32 31
 _log_format = logging.Formatter("%(asctime)s [%(levelname)-5s] %(message)s", datefmt="%H:%M:%S")
33 32
 _console = logging.StreamHandler()
33
+_console.setLevel(logging.INFO)
34 34
 _console.setFormatter(_log_format)
35 35
 _root = logging.getLogger()
36 36
 _root.handlers.clear()
37
-_root.setLevel(logging.INFO)
37
+_root.setLevel(logging.DEBUG)
38 38
 _root.addHandler(_console)
39 39
 
40 40
 _log_file = os.environ.get("OCR_LOG_FILE", "")
@@ -47,7 +47,7 @@ if _log_file:
47 47
 logger = logging.getLogger(__name__)
48 48
 app = typer.Typer(add_completion=False, no_args_is_help=True)
49 49
 
50
-MD_EXTENSION: str = ".md"
50
+RAW_EXTENSION: str = ".raw"
51 51
 IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
52 52
 
53 53
 
@@ -61,18 +61,6 @@ def _get_ocr_engine(name: str, main_lang: str):
61 61
     return functools.partial(ocr_image, main_lang=main_lang), "PP-StructureV3", False
62 62
 
63 63
 
64
-def _merge_markdown_files(output_dir: Path, input_name: str, md_files: list[Path]) -> None:
65
-    merged_parts: list[str] = []
66
-    for md_path in sorted(md_files):
67
-        content = md_path.read_text(encoding="utf-8")
68
-        merged_parts.append(f"<!-- page: {md_path.stem} -->\n\n{content}")
69
-    if merged_parts:
70
-        merged = "# Merged document\n\n" + "\n\n---\n\n".join(merged_parts)
71
-        merged_path = output_dir / (input_name + MD_EXTENSION)
72
-        merged_path.write_text(merged, encoding="utf-8")
73
-        logger.info("Объединённый документ: %s", merged_path)
74
-
75
-
76 64
 def _log_low_confidence(raw_json: dict) -> None:
77 65
     layout = raw_json.get("layout_det_res", {}).get("boxes", [])
78 66
     if not layout:
@@ -91,19 +79,13 @@ def _apply_vlm(blocks, input_path: Path) -> None:
91 79
 
92 80
 
93 81
 def _save_result(page, input_path: Path, output_dir: Path) -> None:
94
-    json_path = output_dir / (input_path.stem + ".json")
95
-    if not json_path.exists():
96
-        json_payload = page.raw_json or {
97
-            "blocks": [{"label": b.label, "content": b.content, "bbox": list(b.bbox)}
98
-                       for b in page.blocks]
99
-        }
100
-        json_path.write_text(json.dumps(json_payload, ensure_ascii=False, indent=2), encoding="utf-8")
101
-        logger.debug("JSON сохранён: %s", json_path)
102
-
103
-    md_path = output_dir / (input_path.stem + MD_EXTENSION)
104
-    generate_markdown(page.blocks, md_path)
105
-    logger.debug("Markdown сохранён: %s", md_path)
106
-    logger.info("%s → %s, %s", input_path.name, md_path.name, json_path.name)
82
+    raw_path = output_dir / (input_path.stem + RAW_EXTENSION)
83
+    raw_payload = page.raw_json or {
84
+        "blocks": [{"label": b.label, "content": b.content, "bbox": list(b.bbox)}
85
+                   for b in page.blocks]
86
+    }
87
+    raw_path.write_text(json.dumps(raw_payload, ensure_ascii=False, indent=2), encoding="utf-8")
88
+    logger.info("%s → %s (%d блоков)", input_path.name, raw_path.name, len(page.blocks))
107 89
 
108 90
 
109 91
 def _process_single(
@@ -175,13 +157,6 @@ def image_to_latex(
175 157
             help="Основной язык: ru (eslav_PP-OCRv5_mobile_rec) или en (PP-OCRv6_medium_rec)",
176 158
         ),
177 159
     ] = "ru",
178
-    result_one_document: Annotated[
179
-        bool,
180
-        typer.Option(
181
-            "--no-result-one-document",
182
-            help="Не объединять .md результаты в один документ",
183
-        ),
184
-    ] = True,
185 160
     pause: Annotated[
186 161
         int,
187 162
         typer.Option(
@@ -209,7 +184,7 @@ def image_to_latex(
209 184
         skipped = 0
210 185
         filtered: list[Path] = []
211 186
         for p in image_paths:
212
-            if (out / (p.stem + ".json")).exists():
187
+            if (out / (p.stem + RAW_EXTENSION)).exists():
213 188
                 logger.info("Пропуск %s (уже есть .json)", p.name)
214 189
                 skipped += 1
215 190
             else:
@@ -241,7 +216,20 @@ def image_to_latex(
241 216
         else:
242 217
             for i, img_path in enumerate(image_paths):
243 218
                 logger.info("[%d/%d] %s...", i + 1 + skipped, len(image_paths) + skipped, img_path.name)
244
-                _process_single(ocr_fn, img_path, out, use_vlm)
219
+                ok = False
220
+                for attempt in (1, 2, 3):
221
+                    try:
222
+                        _process_single(ocr_fn, img_path, out, use_vlm)
223
+                        ok = True
224
+                        break
225
+                    except Exception:
226
+                        logger.exception("Ошибка обработки %s (попытка %d/3)", img_path.name, attempt)
227
+                        if attempt < 3:
228
+                            logger.info("Пауза %dс и повтор...", pause)
229
+                            time.sleep(pause)
230
+                if not ok:
231
+                    logger.error("Не удалось обработать %s после 3 попыток", img_path.name)
232
+                    raise SystemExit(1)
245 233
                 if i < len(image_paths) - 1 and pause > 0:
246 234
                     time.sleep(pause)
247 235
 
@@ -249,9 +237,6 @@ def image_to_latex(
249 237
         processed = len(image_paths)
250 238
         avg_dt = total_dt / processed if processed else 0
251 239
 
252
-        if result_one_document:
253
-            _merge_markdown_files(out, input.name, sorted(out.glob("*.md")))
254
-
255 240
         logger.info(
256 241
             "Готово: %d изображений, всего %.1fs, среднее %.1fs/изобр",
257 242
             processed, total_dt, avg_dt,

+ 17
- 36
src/latex/json_to_latex.py View File

@@ -7,6 +7,13 @@ from pathlib import Path
7 7
 
8 8
 from bs4 import BeautifulSoup
9 9
 
10
+from src.markdown.tables import (
11
+    PAGE_NUM,
12
+    is_markdown_table,
13
+    parse_markdown_table,
14
+    strip_formula_delimiters,
15
+)
16
+
10 17
 LATEX_PREAMBLE = r"""\documentclass[12pt,a4paper]{article}
11 18
 \usepackage[T2A]{fontenc}
12 19
 \usepackage[utf8]{inputenc}
@@ -54,32 +61,10 @@ MathJax = {{
54 61
 """
55 62
 
56 63
 MD_HEADING: re.Pattern = re.compile(r"^#{1,6}\s+")
57
-MD_TABLE_SEP: re.Pattern = re.compile(r"^\|?[\s:-]+\|[\s|:-]+\|?$")
58
-
59
-
60
-def _is_md_table(text: str) -> bool:
61
-    lines = text.strip().splitlines()
62
-    if len(lines) < 3:
63
-        return False
64
-    has_sep = any(MD_TABLE_SEP.match(line) for line in lines)
65
-    if not has_sep:
66
-        return False
67
-    return all("|" in line for line in lines)
68
-
69
-
70
-def _parse_md_table(text: str) -> list[list[str]]:
71
-    lines = text.strip().splitlines()
72
-    rows: list[list[str]] = []
73
-    for line in lines:
74
-        if MD_TABLE_SEP.match(line):
75
-            continue
76
-        cells = [c.strip() for c in line.split("|")[1:-1]]
77
-        rows.append(cells)
78
-    return rows
79 64
 
80 65
 
81 66
 def _md_table_to_html(text: str) -> str:
82
-    rows = _parse_md_table(text)
67
+    rows = parse_markdown_table(text)
83 68
     if not rows:
84 69
         return ""
85 70
     html_rows: list[str] = []
@@ -91,7 +76,7 @@ def _md_table_to_html(text: str) -> str:
91 76
 
92 77
 
93 78
 def _md_table_to_latex(text: str) -> str:
94
-    rows = _parse_md_table(text)
79
+    rows = parse_markdown_table(text)
95 80
     if not rows:
96 81
         return ""
97 82
     ncols = max(len(row) for row in rows)
@@ -190,20 +175,17 @@ def _qwen_to_body(data: dict) -> str:
190 175
     lines: list[str] = []
191 176
     for b in data.get("blocks", []):
192 177
         content = b.get("content", "").strip()
193
-        if not content:
178
+        if not content or PAGE_NUM.match(content):
194 179
             continue
195 180
         label = b.get("label", "text")
196 181
 
197 182
         if label == "formula":
198
-            formula = content.strip()
199
-            if (formula.startswith("$$") and formula.endswith("$$")) or \
200
-               (formula.startswith("\\[") and formula.endswith("\\]")):
201
-                formula = formula[2:-2].strip()
183
+            formula = strip_formula_delimiters(content)
202 184
             lines.append(f"\\[\n{formula}\n\\]\n")
203 185
         elif label == "SectionHeader":
204 186
             heading = MD_HEADING.sub("", content).strip()
205 187
             lines.append(f"\\section*{{{_escape_latex(heading)}}}")
206
-        elif _is_md_table(content):
188
+        elif is_markdown_table(content):
207 189
             lines.append(f"\n{_md_table_to_latex(content)}\n")
208 190
         else:
209 191
             lines.append(f"\n{content}\n")
@@ -271,16 +253,15 @@ def _json_block_to_html(block: dict, fmt: str) -> str:
271 253
         content = block.get("content", "").strip()
272 254
         if not content:
273 255
             return ""
256
+        if PAGE_NUM.match(content):
257
+            return ""
274 258
         if label == "formula":
275
-            clean = content.strip()
276
-            for prefix, suffix in [("$$", "$$"), ("\\[", "\\]")]:
277
-                if clean.startswith(prefix) and clean.endswith(suffix):
278
-                    clean = clean[len(prefix):-len(suffix)].strip()
259
+            clean = strip_formula_delimiters(content)
279 260
             return f'\n<div class="equation">$$\n{clean}\n$$</div>'
280 261
         if label == "SectionHeader":
281 262
             heading = MD_HEADING.sub("", content).strip()
282 263
             return f"<h2>{heading}</h2>"
283
-        if _is_md_table(content):
264
+        if is_markdown_table(content):
284 265
             return _md_table_to_html(content)
285 266
         return f"<p>{content}</p>"
286 267
 
@@ -350,7 +331,7 @@ def _generate_all(json_dir: Path) -> None:
350 331
         try:
351 332
             page_html = json_to_html(jp)
352 333
             inner = page_html[page_html.find("<body>") + 6:page_html.find("</body>")]
353
-            pages.append(f'<div class="page-sep">{jp.stem}</div>\n<hr>\n{inner}')
334
+            pages.append(f'<hr>\n{inner}')
354 335
         except (json.JSONDecodeError, KeyError, ValueError):
355 336
             continue
356 337
 

+ 2
- 0
src/markdown/generator.py View File

@@ -4,6 +4,7 @@ import logging
4 4
 import re
5 5
 from pathlib import Path
6 6
 
7
+from src.markdown.tables import fix_md_table
7 8
 from src.models import ParsedBlock
8 9
 
9 10
 logger = logging.getLogger(__name__)
@@ -77,6 +78,7 @@ def _blocks_to_markdown(blocks: list[ParsedBlock]) -> str:
77 78
         elif block.label == "number":
78 79
             lines.append(f"\n{text}\n")
79 80
         else:
81
+            text = fix_md_table(text)
80 82
             lines.append(f"\n{text}\n")
81 83
     return "\n".join(lines)
82 84
 

+ 53
- 0
src/markdown/tables.py View File

@@ -0,0 +1,53 @@
1
+from __future__ import annotations
2
+
3
+import re
4
+
5
+MD_TABLE_SEP: re.Pattern = re.compile(r"^\|?[\s:-]*-[\s|:-]*\|$")
6
+PAGE_NUM: re.Pattern = re.compile(r"^\s*\d{1,4}\s*$")
7
+
8
+
9
+def is_markdown_table(text: str) -> bool:
10
+    lines = text.strip().splitlines()
11
+    if len(lines) < 2:
12
+        return False
13
+    if not all("|" in line for line in lines):
14
+        return False
15
+    if any(MD_TABLE_SEP.match(line) for line in lines):
16
+        return True
17
+    data_lines = [l for l in lines if not MD_TABLE_SEP.match(l)]
18
+    return len(data_lines) >= 2
19
+
20
+
21
+def parse_markdown_table(text: str) -> list[list[str]]:
22
+    rows: list[list[str]] = []
23
+    for line in text.strip().splitlines():
24
+        if MD_TABLE_SEP.match(line):
25
+            continue
26
+        rows.append([c.strip() for c in line.split("|")[1:-1]])
27
+    return rows
28
+
29
+
30
+def table_ncols(text: str) -> int:
31
+    rows = parse_markdown_table(text)
32
+    if not rows:
33
+        return 0
34
+    return len(rows[0])
35
+
36
+
37
+def fix_md_table(text: str) -> str:
38
+    lines = text.strip().splitlines()
39
+    if len(lines) < 2 or not all("|" in line for line in lines):
40
+        return text
41
+    if any(MD_TABLE_SEP.match(line) for line in lines):
42
+        return text
43
+    ncols = len(lines[0].split("|")[1:-1])
44
+    lines.insert(1, "|" + "|".join(["---"] * ncols) + "|")
45
+    return "\n".join(lines)
46
+
47
+
48
+def strip_formula_delimiters(text: str) -> str:
49
+    text = text.strip()
50
+    for prefix, suffix in (("$$", "$$"), ("\\[", "\\]")):
51
+        if text.startswith(prefix) and text.endswith(suffix):
52
+            return text[len(prefix):-len(suffix)].strip()
53
+    return text

+ 0
- 3
src/models.py View File

@@ -9,12 +9,9 @@ class ParsedBlock:
9 9
     label: str
10 10
     content: str
11 11
     bbox: tuple[int, int, int, int]
12
-    confidence: float = 0.9
13 12
 
14 13
 
15 14
 @dataclass
16 15
 class OcrPageResult:
17 16
     blocks: list[ParsedBlock] = field(default_factory=list)
18 17
     raw_json: dict[str, Any] = field(default_factory=dict)
19
-    width: int = 0
20
-    height: int = 0

+ 28
- 10
src/ocr/external_vlm_engine.py View File

@@ -34,8 +34,6 @@ def _parse_response_to_blocks(text: str, image) -> OcrPageResult:
34 34
     return OcrPageResult(
35 35
         blocks=blocks,
36 36
         raw_json={"blocks": [{"label": b.label, "content": b.content} for b in blocks]},
37
-        width=image.shape[1],
38
-        height=image.shape[0],
39 37
     )
40 38
 
41 39
 
@@ -50,19 +48,39 @@ def external_vlm_ocr_image(image_path: str) -> OcrPageResult:
50 48
 
51 49
     t0 = time.time()
52 50
     b64 = image_to_base64(scaled)
53
-    text = call_qwen_vlm(
51
+    text, usage = call_qwen_vlm(
54 52
         b64,
55 53
         (
56
-            "Extract ALL text from this document page. "
57
-            "For tables use | column | format |. "
58
-            "For formulas use $$...$$ or $...$. "
59
-            "Preserve structure: headers as ##, paragraphs as text. "
60
-            "Return ONLY the extracted content, no commentary."
54
+            "You are a high-accuracy document OCR engine.\n\n"
55
+            "Extract ALL visible textual and mathematical content from the image.\n\n"
56
+            "IMPORTANT:\n"
57
+            "- Do not summarize, interpret, paraphrase, or omit content.\n"
58
+            "- Do not invent or correct content that is unclear.\n"
59
+            "- Preserve the original wording, spelling, numbers, symbols, punctuation, and order.\n"
60
+            "- Preserve the reading order of the document.\n"
61
+            "- Include headers, footers, page numbers, captions, footnotes, marginal text, "
62
+            "labels, and text inside figures whenever readable.\n\n"
63
+            "DOCUMENT STRUCTURE:\n"
64
+            "1. Headings: Use Markdown headings (#, ##, ###) according to their visual hierarchy.\n"
65
+            "2. Paragraphs: Preserve paragraphs and line breaks where structurally meaningful.\n"
66
+            "3. Lists: Preserve numbered and bulleted lists.\n"
67
+            "4. Tables: Reconstruct every table completely. Preserve all rows and columns. "
68
+            "Do not omit empty cells. Use Markdown table syntax. Do not convert a table into prose.\n"
69
+            "5. Formulas: Reproduce every formula using LaTeX: inline $...$, display $$...$$. "
70
+            "Preserve subscripts, superscripts, fractions, Greek letters, operators, parentheses.\n"
71
+            "6. Figures: Preserve readable text inside them. For non-textual graphics: [FIGURE].\n"
72
+            "7. Uncertain text: Never guess. Use [?] for unrecognized characters.\n"
73
+            "8. Language: Preserve the original language. Do not translate.\n\n"
74
+            "OUTPUT: Return ONLY the reconstructed document. "
75
+            "No explanations, comments, confidence statements, or Markdown code fences."
61 76
         ),
62
-        max_tokens=4000,
77
+        max_tokens=12000,
63 78
     )
64 79
     dt = time.time() - t0
65
-    logger.debug("VLM: ответ за %.1fs", dt)
80
+    in_tok = usage.get("input_tokens", "?")
81
+    out_tok = usage.get("output_tokens", "?")
82
+    logger.info("VLM: ответ за %.1fs, токенов: in=%s out=%s", dt, in_tok, out_tok)
83
+    logger.debug("VLM: %dx%d px → in=%s out=%s tokens, %.1fs", scaled.shape[1], scaled.shape[0], in_tok, out_tok, dt)
66 84
 
67 85
     if not text:
68 86
         raise RuntimeError("VLM вернул пустой ответ")

+ 1
- 2
src/ocr/paddle_engine.py View File

@@ -41,7 +41,6 @@ class OcrEngine:
41 41
         if image is None:
42 42
             raise FileNotFoundError(f"Не удалось загрузить изображение: {image_path}")
43 43
 
44
-        h, w = image.shape[:2]
45 44
         if self._pipeline is None:
46 45
             self._pipeline = create_pipeline(
47 46
                 config=self._config,
@@ -51,7 +50,7 @@ class OcrEngine:
51 50
         raw_results = list(self._pipeline.predict(image))
52 51
 
53 52
         blocks, raw_json = self._parse_results(raw_results)
54
-        return OcrPageResult(blocks=blocks, raw_json=raw_json, width=w, height=h)
53
+        return OcrPageResult(blocks=blocks, raw_json=raw_json)
55 54
 
56 55
     @staticmethod
57 56
     def _parse_results(raw_results: list[Any]) -> tuple[list[ParsedBlock], dict[str, Any]]:

+ 3
- 28
src/ocr/surya_engine.py View File

@@ -66,18 +66,12 @@ class SuryaEngine:
66 66
             )
67 67
             raise ImportError(msg) from e
68 68
 
69
-    def process(self, image_path: str) -> OcrPageResult:
70
-        from PIL import Image
71
-
72
-        image = Image.open(image_path)
73
-        return self._process_image(image)
74
-
75 69
     def process_batch(self, image_paths: list[str]) -> list[OcrPageResult]:
76 70
         from PIL import Image
77 71
 
78 72
         images = [_pre_scale(Image.open(p)) for p in image_paths]
79 73
         results = self._predictor(images)
80
-        return [self._to_result(results[i], images[i]) if i < len(results) else OcrPageResult()
74
+        return [self._to_result(results[i]) if i < len(results) else OcrPageResult()
81 75
                 for i in range(len(images))]
82 76
 
83 77
     def _health_check(self) -> bool:
@@ -94,35 +88,21 @@ class SuryaEngine:
94 88
             logger.warning("llama-server health check failed")
95 89
             return False
96 90
 
97
-    def _process_image(self, image: PILImage.Image) -> OcrPageResult:
98
-        scaled = _pre_scale(image)
99
-        results = self._predictor([scaled])
100
-        if results:
101
-            return self._to_result(results[0], scaled)
102
-        return OcrPageResult()
103
-
104 91
     @staticmethod
105
-    def _to_result(page, image: PILImage.Image) -> OcrPageResult:
92
+    def _to_result(page) -> OcrPageResult:
106 93
         blocks: list[ParsedBlock] = []
107 94
         for blk in getattr(page, "blocks", []):
108 95
             html = getattr(blk, "html", "") or ""
109 96
             label = getattr(blk, "label", "text")
110 97
             bbox = getattr(blk, "bbox", [0, 0, 0, 0])
111
-            confidence = float(getattr(blk, "confidence", 0.9))
112 98
             blocks.append(
113 99
                 ParsedBlock(
114 100
                     label=label,
115 101
                     content=html,
116 102
                     bbox=(int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])),
117
-                    confidence=confidence,
118 103
                 )
119 104
             )
120
-        return OcrPageResult(
121
-            blocks=blocks,
122
-            raw_json={},
123
-            width=image.width,
124
-            height=image.height,
125
-        )
105
+        return OcrPageResult(blocks=blocks, raw_json={})
126 106
 
127 107
 
128 108
 def _pre_scale(image: PILImage.Image) -> PILImage.Image:
@@ -137,11 +117,6 @@ def _get_surya_engine() -> SuryaEngine:
137 117
     return SuryaEngine()
138 118
 
139 119
 
140
-def surya_ocr_image(image_path: str) -> OcrPageResult:
141
-    engine = _get_surya_engine()
142
-    return engine.process(image_path)
143
-
144
-
145 120
 def surya_ocr_batch(image_paths: list[str]) -> list[OcrPageResult]:
146 121
     engine = _get_surya_engine()
147 122
     if not engine._health_check():

+ 21
- 12
src/postprocess/qwen_client.py View File

@@ -32,11 +32,13 @@ def call_qwen_vlm(
32 32
     prompt: str,
33 33
     model: str = DEFAULT_MODEL,
34 34
     max_tokens: int = 300,
35
-) -> str | None:
36
-    """Отправляет изображение + prompt в Qwen3.8 Max API и возвращает текст ответа."""
35
+) -> tuple[str | None, dict]:
36
+    """Отправляет изображение + prompt в Qwen3.8 Max API.
37
+    Возвращает (текст, usage_dict)."""
38
+    empty_usage: dict = {}
37 39
     if not _api_key():
38 40
         logger.warning("OPENCODE_API_KEY не задан")
39
-        return None
41
+        return None, empty_usage
40 42
 
41 43
     messages = [{
42 44
         "role": "user",
@@ -46,7 +48,7 @@ def call_qwen_vlm(
46 48
         ],
47 49
     }]
48 50
 
49
-    for attempt in (1, 2):
51
+    for attempt in (1, 2, 3):
50 52
         try:
51 53
             r = requests.post(
52 54
                 API_URL,
@@ -56,18 +58,25 @@ def call_qwen_vlm(
56 58
             )
57 59
             r.raise_for_status()
58 60
             data = r.json()
61
+            text = ""
59 62
             for item in data.get("content", []):
60 63
                 if item.get("type") == "text":
61
-                    return item.get("text", "").strip()
62
-            return None
64
+                    text = item.get("text", "").strip()
65
+            stop = data.get("stop_reason", "")
66
+            usage = data.get("usage", {})
67
+            if text and stop == "max_tokens":
68
+                logger.warning("Qwen API: ответ обрезан (max_tokens=%d), stop_reason=%s", max_tokens, stop)
69
+            return text or None, usage
63 70
         except requests.HTTPError as e:
64
-            if e.response is not None and e.response.status_code == 500 and attempt == 1:
65
-                logger.info("Qwen API: HTTP 500, ожидание 5с и повтор...")
66
-                _time.sleep(5)
71
+            status = e.response.status_code if e.response is not None else 0
72
+            if status in (500, 503) and attempt < 3:
73
+                delay = 5 * attempt
74
+                logger.info("Qwen API: HTTP %d, ожидание %dс и повтор...", status, delay)
75
+                _time.sleep(delay)
67 76
                 continue
68 77
             logger.warning("Qwen API: ошибка — %s", e)
69
-            return None
78
+            return None, {}
70 79
         except (requests.RequestException, KeyError, IndexError) as e:
71 80
             logger.warning("Qwen API: ошибка — %s", e)
72
-            return None
73
-    return None
81
+            return None, {}
82
+    return None, {}

+ 1
- 1
src/postprocess/vlm_corrector.py View File

@@ -29,7 +29,7 @@ def vlm_correct_block(block: ParsedBlock, image_path: str) -> None:
29 29
     b64 = image_to_base64(crop)
30 30
     total_sent = len(b64) // 1024
31 31
     logger.info("VLM: отправка %dx%d px → %d KB (bbox %d,%d,%d,%d)", x2 - x1, y2 - y1, total_sent, x1, y1, x2, y2)
32
-    text = call_qwen_vlm(
32
+    text, _ = call_qwen_vlm(
33 33
         b64,
34 34
         "Extract ALL visible text from this image region. Preserve LaTeX math. Return ONLY the extracted text.",
35 35
         max_tokens=300,

+ 3
- 1
src/split/slicer.py View File

@@ -264,7 +264,9 @@ def unwarp_image(image: np.ndarray) -> np.ndarray:
264 264
         approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
265 265
         if len(approx) == 4:
266 266
             rect = _order_points(approx.reshape(4, 2))
267
-            return _apply_perspective(image, rect)
267
+            result = _apply_perspective(image, rect)
268
+            if result.shape[0] >= h * 0.8 and result.shape[1] >= w * 0.8:
269
+                return result
268 270
     return image
269 271
 
270 272
 

+ 61
- 1
stage2_results.md View File

@@ -286,4 +286,64 @@ src/
286 286
 6. **VLM-корректор (Qwen3.8 Max)** — отличный, но API
287 287
 7. **Surya 2** — лучший русский OCR из коробки, CPU-оптимизации дали -30% времени
288 288
 8. **Гибридный подход**: Surya для текста + VLM для таблиц = лучший баланс
289
-9. **CPU-ограничения** — основной фактор, GPU сделал бы Surya/PaddleOCR-VL идеальным выбором
289
+9. **CPU-ограничения** — основной фактор, GPU сделал бы Surya/PaddleOCR-VL идеальным выбором
290
+
291
+---
292
+
293
+## Трёхэтапный пайплайн (рефакторинг)
294
+
295
+### Проблема
296
+Изначально `image_ocr.py` делал всё: OCR → фильтрация → объединение → LaTeX. Постпроцессинг **изменял `.json`-файлы на месте**, что приводило к накоплению ошибок (повторная генерация портила данные, таблицы дублировались).
297
+
298
+### Решение: `.raw` write-once + Stage 3
299
+
300
+```
301
+image_prepare  (Stage 1) → подготовка изображений
302
+image_ocr      (Stage 2) → OCR → .raw  (сырой ответ, НИКОГДА не изменяется)
303
+generate_result (Stage 3) → постпроцессинг → .json + .md + .tex + .html
304
+```
305
+
306
+Ключевой принцип: **`.raw` — неизменяемый**. Все операции постпроцессинга читают `.raw` и пишут результат в отдельные файлы. Повторный запуск Stage 3 даёт идентичный результат (идемпотентность).
307
+
308
+### VLM-промпт (Qwen3.8 Max) — ключевое улучшение ✅
309
+
310
+Старый промпт (4 строки) → новый (детальный, 30 строк):
311
+
312
+| Аспект | Старый | Новый |
313
+|---|---|---|
314
+| Роль | — | "high-accuracy document OCR engine" |
315
+| Анти-галлюцинации | — | "Do not summarize, interpret, paraphrase, or omit" |
316
+| Порядок чтения | — | "Preserve the reading order" |
317
+| Таблицы | "| col | format |" | "Reconstruct every table completely. Do not omit empty cells." |
318
+| Неуверенный текст | — | [?] вместо догадок |
319
+| Язык | — | "Do not translate" |
320
+| max_tokens | 4000 | 8000 (+предупреждение о truncation) |
321
+
322
+**Эффект:** 4 блока → 11 блоков на одной странице; таблицы полностью восстанавливаются.
323
+
324
+### Восстановление таблиц между страницами ✅
325
+
326
+Таблицы, разорванные между страницами (заголовок на стр. N, строки на стр. N+1...), объединяются по кол-ву колонок. Цепочки из 2-4 страниц склеиваются в одну таблицу (например, Таблица 76 на 4 страницах).
327
+
328
+### Unwarp-баг и фикс ⚠️→✅
329
+
330
+Первая версия unwarp искала 4-угольный контур и warp'ила в него — на плоских сканах находила внутренние таблицы и обрезала до 40% высоты. Фикс: порог `min_area ≥ 30%` + проверка, что результат ≥ 80% оригинала. Для плоского сканера unwarp = NOP.
331
+
332
+### Очистка формул для LLM ✅
333
+
334
+`\text{score\_beh}` → `score_beh` — формулы читаемы для LLM без LaTeX-шума.
335
+
336
+### Удалено за ненадобностью
337
+
338
+| Что | Причина |
339
+|---|---|
340
+| LLM-корректор (Qwen2.5-7B) | галлюцинирует, слепой (не видит изображение), +60s/стр |
341
+| Binarization (adaptive threshold) | рвёт буквы, вредна для нейросетевого OCR |
342
+| Upscale ×2 | max_side_limit=4000 съедает |
343
+| `surya2html.py`, `tex2html.py` | дубликаты json_to_latex.py |
344
+| `confidence`, `width`, `height` в моделях | write-only поля |
345
+| single-image Surya path | используется только batch |
346
+
347
+### Общий модуль таблиц ✅
348
+
349
+`src/markdown/tables.py` — единые `MD_TABLE_SEP`, `PAGE_NUM`, `is_markdown_table()`, `parse_markdown_table()`, `table_ncols()`, `fix_md_table()`, `strip_formula_delimiters()`. Устранена тройная дупликация regex между `generate_result.py`, `generator.py`, `json_to_latex.py`.

Loading…
Cancel
Save