Преглед изворни кода

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

master
Evgeniy Ierusalimov пре 2 недеља
родитељ
комит
3a0d25126c

+ 1
- 0
.gitignore Прегледај датотеку

16
 .vscode/
16
 .vscode/
17
 .DS_Store
17
 .DS_Store
18
 
18
 
19
+*.log

+ 107
- 73
README.md Прегледај датотеку

22
 JPEG/TIFF/PNG (многостраничный скан)
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
     │ --slice / --slice-auto
26
     │ --slice / --slice-auto
27
     │ --pre-rotate, --border
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
 | `--input` / `-i` | Файл изображения **или** каталог | required |
137
 | `--input` / `-i` | Файл изображения **или** каталог | required |
135
 | `--output-dir` / `-o` | Каталог вывода | рядом с `--input` |
138
 | `--output-dir` / `-o` | Каталог вывода | рядом с `--input` |
136
 | `--ocr-engine` | **Обязательный:** `paddle`, `surya` или `external-qwen3` | — |
139
 | `--ocr-engine` | **Обязательный:** `paddle`, `surya` или `external-qwen3` | — |
140
+| `--main-lang` | Основной язык: `ru` (eslav rec) или `en` (PP-OCRv6 rec) | `ru` |
137
 | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
141
 | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
138
-| `--main-lang` | Основной язык: `ru` (eslav) или `en` (PP-OCRv6 rec) | `ru` |
139
 | `--pause` | Пауза между изображениями (сек) | 5 |
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
 ```bash
147
 ```bash
145
 # Одна страница — PaddleOCR
148
 # Одна страница — PaddleOCR
146
 python -m src.image_ocr -i page.jpg --ocr-engine paddle
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
 python -m src.image_ocr -i ./pages/ --ocr-engine surya
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
 <details>
180
 <details>
195
 │                                                        работает с            │
208
 │                                                        работает с            │
196
 │                                                        --ocr-engine          │
209
 │                                                        --ocr-engine          │
197
 │                                                        external-qwen3        │
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
 │    --pause                       <int range>           Пауза между           │
216
 │    --pause                       <int range>           Пауза между           │
203
 │                                  [0<=x<=600]           изображениями в       │
217
 │                                  [0<=x<=600]           изображениями в       │
204
 │                                                        секундах (по          │
218
 │                                                        секундах (по          │
210
 ```
224
 ```
211
 </details>
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
 ### Debug-лог
247
 ### Debug-лог
214
 
248
 
215
 Для отладки зависаний и падений — файл с полным логом всех этапов обработки.
249
 Для отладки зависаний и падений — файл с полным логом всех этапов обработки.
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
 ```bash
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
 ## Качество OCR
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
 
385
 
361
 ```
386
 ```
362
 src/
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
   ocr/
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
   postprocess/
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
   latex/
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 Прегледај датотеку

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 Прегледај датотеку

12
 
12
 
13
 from src.cli_utils import run_main
13
 from src.cli_utils import run_main
14
 from src.env import load_env
14
 from src.env import load_env
15
-from src.markdown.generator import generate_markdown
16
 from src.ocr.paddle_engine import ocr_image
15
 from src.ocr.paddle_engine import ocr_image
17
 from src.postprocess.fixups import fix_ocr_errors
16
 from src.postprocess.fixups import fix_ocr_errors
18
 
17
 
31
 # Настройка логирования
30
 # Настройка логирования
32
 _log_format = logging.Formatter("%(asctime)s [%(levelname)-5s] %(message)s", datefmt="%H:%M:%S")
31
 _log_format = logging.Formatter("%(asctime)s [%(levelname)-5s] %(message)s", datefmt="%H:%M:%S")
33
 _console = logging.StreamHandler()
32
 _console = logging.StreamHandler()
33
+_console.setLevel(logging.INFO)
34
 _console.setFormatter(_log_format)
34
 _console.setFormatter(_log_format)
35
 _root = logging.getLogger()
35
 _root = logging.getLogger()
36
 _root.handlers.clear()
36
 _root.handlers.clear()
37
-_root.setLevel(logging.INFO)
37
+_root.setLevel(logging.DEBUG)
38
 _root.addHandler(_console)
38
 _root.addHandler(_console)
39
 
39
 
40
 _log_file = os.environ.get("OCR_LOG_FILE", "")
40
 _log_file = os.environ.get("OCR_LOG_FILE", "")
47
 logger = logging.getLogger(__name__)
47
 logger = logging.getLogger(__name__)
48
 app = typer.Typer(add_completion=False, no_args_is_help=True)
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
 IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
51
 IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
52
 
52
 
53
 
53
 
61
     return functools.partial(ocr_image, main_lang=main_lang), "PP-StructureV3", False
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
 def _log_low_confidence(raw_json: dict) -> None:
64
 def _log_low_confidence(raw_json: dict) -> None:
77
     layout = raw_json.get("layout_det_res", {}).get("boxes", [])
65
     layout = raw_json.get("layout_det_res", {}).get("boxes", [])
78
     if not layout:
66
     if not layout:
91
 
79
 
92
 
80
 
93
 def _save_result(page, input_path: Path, output_dir: Path) -> None:
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
 def _process_single(
91
 def _process_single(
175
             help="Основной язык: ru (eslav_PP-OCRv5_mobile_rec) или en (PP-OCRv6_medium_rec)",
157
             help="Основной язык: ru (eslav_PP-OCRv5_mobile_rec) или en (PP-OCRv6_medium_rec)",
176
         ),
158
         ),
177
     ] = "ru",
159
     ] = "ru",
178
-    result_one_document: Annotated[
179
-        bool,
180
-        typer.Option(
181
-            "--no-result-one-document",
182
-            help="Не объединять .md результаты в один документ",
183
-        ),
184
-    ] = True,
185
     pause: Annotated[
160
     pause: Annotated[
186
         int,
161
         int,
187
         typer.Option(
162
         typer.Option(
209
         skipped = 0
184
         skipped = 0
210
         filtered: list[Path] = []
185
         filtered: list[Path] = []
211
         for p in image_paths:
186
         for p in image_paths:
212
-            if (out / (p.stem + ".json")).exists():
187
+            if (out / (p.stem + RAW_EXTENSION)).exists():
213
                 logger.info("Пропуск %s (уже есть .json)", p.name)
188
                 logger.info("Пропуск %s (уже есть .json)", p.name)
214
                 skipped += 1
189
                 skipped += 1
215
             else:
190
             else:
241
         else:
216
         else:
242
             for i, img_path in enumerate(image_paths):
217
             for i, img_path in enumerate(image_paths):
243
                 logger.info("[%d/%d] %s...", i + 1 + skipped, len(image_paths) + skipped, img_path.name)
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
                 if i < len(image_paths) - 1 and pause > 0:
233
                 if i < len(image_paths) - 1 and pause > 0:
246
                     time.sleep(pause)
234
                     time.sleep(pause)
247
 
235
 
249
         processed = len(image_paths)
237
         processed = len(image_paths)
250
         avg_dt = total_dt / processed if processed else 0
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
         logger.info(
240
         logger.info(
256
             "Готово: %d изображений, всего %.1fs, среднее %.1fs/изобр",
241
             "Готово: %d изображений, всего %.1fs, среднее %.1fs/изобр",
257
             processed, total_dt, avg_dt,
242
             processed, total_dt, avg_dt,

+ 17
- 36
src/latex/json_to_latex.py Прегледај датотеку

7
 
7
 
8
 from bs4 import BeautifulSoup
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
 LATEX_PREAMBLE = r"""\documentclass[12pt,a4paper]{article}
17
 LATEX_PREAMBLE = r"""\documentclass[12pt,a4paper]{article}
11
 \usepackage[T2A]{fontenc}
18
 \usepackage[T2A]{fontenc}
12
 \usepackage[utf8]{inputenc}
19
 \usepackage[utf8]{inputenc}
54
 """
61
 """
55
 
62
 
56
 MD_HEADING: re.Pattern = re.compile(r"^#{1,6}\s+")
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
 def _md_table_to_html(text: str) -> str:
66
 def _md_table_to_html(text: str) -> str:
82
-    rows = _parse_md_table(text)
67
+    rows = parse_markdown_table(text)
83
     if not rows:
68
     if not rows:
84
         return ""
69
         return ""
85
     html_rows: list[str] = []
70
     html_rows: list[str] = []
91
 
76
 
92
 
77
 
93
 def _md_table_to_latex(text: str) -> str:
78
 def _md_table_to_latex(text: str) -> str:
94
-    rows = _parse_md_table(text)
79
+    rows = parse_markdown_table(text)
95
     if not rows:
80
     if not rows:
96
         return ""
81
         return ""
97
     ncols = max(len(row) for row in rows)
82
     ncols = max(len(row) for row in rows)
190
     lines: list[str] = []
175
     lines: list[str] = []
191
     for b in data.get("blocks", []):
176
     for b in data.get("blocks", []):
192
         content = b.get("content", "").strip()
177
         content = b.get("content", "").strip()
193
-        if not content:
178
+        if not content or PAGE_NUM.match(content):
194
             continue
179
             continue
195
         label = b.get("label", "text")
180
         label = b.get("label", "text")
196
 
181
 
197
         if label == "formula":
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
             lines.append(f"\\[\n{formula}\n\\]\n")
184
             lines.append(f"\\[\n{formula}\n\\]\n")
203
         elif label == "SectionHeader":
185
         elif label == "SectionHeader":
204
             heading = MD_HEADING.sub("", content).strip()
186
             heading = MD_HEADING.sub("", content).strip()
205
             lines.append(f"\\section*{{{_escape_latex(heading)}}}")
187
             lines.append(f"\\section*{{{_escape_latex(heading)}}}")
206
-        elif _is_md_table(content):
188
+        elif is_markdown_table(content):
207
             lines.append(f"\n{_md_table_to_latex(content)}\n")
189
             lines.append(f"\n{_md_table_to_latex(content)}\n")
208
         else:
190
         else:
209
             lines.append(f"\n{content}\n")
191
             lines.append(f"\n{content}\n")
271
         content = block.get("content", "").strip()
253
         content = block.get("content", "").strip()
272
         if not content:
254
         if not content:
273
             return ""
255
             return ""
256
+        if PAGE_NUM.match(content):
257
+            return ""
274
         if label == "formula":
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
             return f'\n<div class="equation">$$\n{clean}\n$$</div>'
260
             return f'\n<div class="equation">$$\n{clean}\n$$</div>'
280
         if label == "SectionHeader":
261
         if label == "SectionHeader":
281
             heading = MD_HEADING.sub("", content).strip()
262
             heading = MD_HEADING.sub("", content).strip()
282
             return f"<h2>{heading}</h2>"
263
             return f"<h2>{heading}</h2>"
283
-        if _is_md_table(content):
264
+        if is_markdown_table(content):
284
             return _md_table_to_html(content)
265
             return _md_table_to_html(content)
285
         return f"<p>{content}</p>"
266
         return f"<p>{content}</p>"
286
 
267
 
350
         try:
331
         try:
351
             page_html = json_to_html(jp)
332
             page_html = json_to_html(jp)
352
             inner = page_html[page_html.find("<body>") + 6:page_html.find("</body>")]
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
         except (json.JSONDecodeError, KeyError, ValueError):
335
         except (json.JSONDecodeError, KeyError, ValueError):
355
             continue
336
             continue
356
 
337
 

+ 2
- 0
src/markdown/generator.py Прегледај датотеку

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

+ 53
- 0
src/markdown/tables.py Прегледај датотеку

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 Прегледај датотеку

9
     label: str
9
     label: str
10
     content: str
10
     content: str
11
     bbox: tuple[int, int, int, int]
11
     bbox: tuple[int, int, int, int]
12
-    confidence: float = 0.9
13
 
12
 
14
 
13
 
15
 @dataclass
14
 @dataclass
16
 class OcrPageResult:
15
 class OcrPageResult:
17
     blocks: list[ParsedBlock] = field(default_factory=list)
16
     blocks: list[ParsedBlock] = field(default_factory=list)
18
     raw_json: dict[str, Any] = field(default_factory=dict)
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 Прегледај датотеку

34
     return OcrPageResult(
34
     return OcrPageResult(
35
         blocks=blocks,
35
         blocks=blocks,
36
         raw_json={"blocks": [{"label": b.label, "content": b.content} for b in blocks]},
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
 
48
 
51
     t0 = time.time()
49
     t0 = time.time()
52
     b64 = image_to_base64(scaled)
50
     b64 = image_to_base64(scaled)
53
-    text = call_qwen_vlm(
51
+    text, usage = call_qwen_vlm(
54
         b64,
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
     dt = time.time() - t0
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
     if not text:
85
     if not text:
68
         raise RuntimeError("VLM вернул пустой ответ")
86
         raise RuntimeError("VLM вернул пустой ответ")

+ 1
- 2
src/ocr/paddle_engine.py Прегледај датотеку

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

+ 3
- 28
src/ocr/surya_engine.py Прегледај датотеку

66
             )
66
             )
67
             raise ImportError(msg) from e
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
     def process_batch(self, image_paths: list[str]) -> list[OcrPageResult]:
69
     def process_batch(self, image_paths: list[str]) -> list[OcrPageResult]:
76
         from PIL import Image
70
         from PIL import Image
77
 
71
 
78
         images = [_pre_scale(Image.open(p)) for p in image_paths]
72
         images = [_pre_scale(Image.open(p)) for p in image_paths]
79
         results = self._predictor(images)
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
                 for i in range(len(images))]
75
                 for i in range(len(images))]
82
 
76
 
83
     def _health_check(self) -> bool:
77
     def _health_check(self) -> bool:
94
             logger.warning("llama-server health check failed")
88
             logger.warning("llama-server health check failed")
95
             return False
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
     @staticmethod
91
     @staticmethod
105
-    def _to_result(page, image: PILImage.Image) -> OcrPageResult:
92
+    def _to_result(page) -> OcrPageResult:
106
         blocks: list[ParsedBlock] = []
93
         blocks: list[ParsedBlock] = []
107
         for blk in getattr(page, "blocks", []):
94
         for blk in getattr(page, "blocks", []):
108
             html = getattr(blk, "html", "") or ""
95
             html = getattr(blk, "html", "") or ""
109
             label = getattr(blk, "label", "text")
96
             label = getattr(blk, "label", "text")
110
             bbox = getattr(blk, "bbox", [0, 0, 0, 0])
97
             bbox = getattr(blk, "bbox", [0, 0, 0, 0])
111
-            confidence = float(getattr(blk, "confidence", 0.9))
112
             blocks.append(
98
             blocks.append(
113
                 ParsedBlock(
99
                 ParsedBlock(
114
                     label=label,
100
                     label=label,
115
                     content=html,
101
                     content=html,
116
                     bbox=(int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])),
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
 def _pre_scale(image: PILImage.Image) -> PILImage.Image:
108
 def _pre_scale(image: PILImage.Image) -> PILImage.Image:
137
     return SuryaEngine()
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
 def surya_ocr_batch(image_paths: list[str]) -> list[OcrPageResult]:
120
 def surya_ocr_batch(image_paths: list[str]) -> list[OcrPageResult]:
146
     engine = _get_surya_engine()
121
     engine = _get_surya_engine()
147
     if not engine._health_check():
122
     if not engine._health_check():

+ 21
- 12
src/postprocess/qwen_client.py Прегледај датотеку

32
     prompt: str,
32
     prompt: str,
33
     model: str = DEFAULT_MODEL,
33
     model: str = DEFAULT_MODEL,
34
     max_tokens: int = 300,
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
     if not _api_key():
39
     if not _api_key():
38
         logger.warning("OPENCODE_API_KEY не задан")
40
         logger.warning("OPENCODE_API_KEY не задан")
39
-        return None
41
+        return None, empty_usage
40
 
42
 
41
     messages = [{
43
     messages = [{
42
         "role": "user",
44
         "role": "user",
46
         ],
48
         ],
47
     }]
49
     }]
48
 
50
 
49
-    for attempt in (1, 2):
51
+    for attempt in (1, 2, 3):
50
         try:
52
         try:
51
             r = requests.post(
53
             r = requests.post(
52
                 API_URL,
54
                 API_URL,
56
             )
58
             )
57
             r.raise_for_status()
59
             r.raise_for_status()
58
             data = r.json()
60
             data = r.json()
61
+            text = ""
59
             for item in data.get("content", []):
62
             for item in data.get("content", []):
60
                 if item.get("type") == "text":
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
         except requests.HTTPError as e:
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
                 continue
76
                 continue
68
             logger.warning("Qwen API: ошибка — %s", e)
77
             logger.warning("Qwen API: ошибка — %s", e)
69
-            return None
78
+            return None, {}
70
         except (requests.RequestException, KeyError, IndexError) as e:
79
         except (requests.RequestException, KeyError, IndexError) as e:
71
             logger.warning("Qwen API: ошибка — %s", e)
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 Прегледај датотеку

29
     b64 = image_to_base64(crop)
29
     b64 = image_to_base64(crop)
30
     total_sent = len(b64) // 1024
30
     total_sent = len(b64) // 1024
31
     logger.info("VLM: отправка %dx%d px → %d KB (bbox %d,%d,%d,%d)", x2 - x1, y2 - y1, total_sent, x1, y1, x2, y2)
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
         b64,
33
         b64,
34
         "Extract ALL visible text from this image region. Preserve LaTeX math. Return ONLY the extracted text.",
34
         "Extract ALL visible text from this image region. Preserve LaTeX math. Return ONLY the extracted text.",
35
         max_tokens=300,
35
         max_tokens=300,

+ 3
- 1
src/split/slicer.py Прегледај датотеку

264
         approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
264
         approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
265
         if len(approx) == 4:
265
         if len(approx) == 4:
266
             rect = _order_points(approx.reshape(4, 2))
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
     return image
270
     return image
269
 
271
 
270
 
272
 

+ 61
- 1
stage2_results.md Прегледај датотеку

286
 6. **VLM-корректор (Qwen3.8 Max)** — отличный, но API
286
 6. **VLM-корректор (Qwen3.8 Max)** — отличный, но API
287
 7. **Surya 2** — лучший русский OCR из коробки, CPU-оптимизации дали -30% времени
287
 7. **Surya 2** — лучший русский OCR из коробки, CPU-оптимизации дали -30% времени
288
 8. **Гибридный подход**: Surya для текста + VLM для таблиц = лучший баланс
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…
Откажи
Сачувај