소스 검색

updated PaddleOCR with PPv6, introduced helping param --main-lang, remove local llm qwen2.5:7b due to hallucinations

master
Evgeniy Ierusalimov 3 일 전
부모
커밋
a154a1627f
11개의 변경된 파일185개의 추가작업 그리고 157개의 파일을 삭제
  1. 6
    13
      README.md
  2. 1
    5
      installation.md
  3. 3
    5
      requirements.txt
  4. 15
    22
      src/image_ocr.py
  5. 7
    2
      src/latex/json_to_latex.py
  6. 18
    11
      src/ocr/paddle_engine.py
  7. 0
    44
      src/postprocess/llm_corrector.py
  8. 2
    0
      src/split/slicer.py
  9. 131
    53
      stage2_results.md
  10. 1
    1
      tests/test_post_crop.py
  11. 1
    1
      tests/test_slicer.py

+ 6
- 13
README.md 파일 보기

@@ -38,10 +38,8 @@ src/image_ocr.py           ← Stage 2: OCR + Markdown
38 38
     │ --llm, --vlm, --force-ocr
39 39
     │ --pause 5 (по умолчанию)
40 40
     │ --result-one-document    (по умолчанию ON, объединить в один .md)
41
-    │
42 41
     ├─ PP-StructureV3 / Surya 2    (OCR)
43 42
     ├─ fixups                      (VaR, V^2)
44
-    ├─ LLM corrector               (Qwen2.5-7B, local)
45 43
     ├─ VLM corrector               (Qwen3.8 Max, API)
46 44
47 45
@@ -136,8 +134,8 @@ python -m src.image_prepare -i old_scan.jpg --denoise -s 2:2 -b 10
136 134
 | `--input` / `-i` | Файл изображения **или** каталог | required |
137 135
 | `--output-dir` / `-o` | Каталог вывода | рядом с `--input` |
138 136
 | `--ocr-engine` | **Обязательный:** `paddle`, `surya` или `external-qwen3` | — |
139
-| `--fix-by-llm` | Исправить ошибки локальным LLM (Qwen2.5-7B) | OFF |
140 137
 | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
138
+| `--main-lang` | Основной язык: `ru` (eslav) или `en` (PP-OCRv6 rec) | `ru` |
141 139
 | `--pause` | Пауза между изображениями (сек) | 5 |
142 140
 | `--no-result-one-document` | Не объединять `.md` в один документ | — |
143 141
 | `--no-post-crop` | Не обрезать по контенту (Stage 1) | — |
@@ -159,8 +157,11 @@ python -m src.image_ocr -i page.jpg --ocr-engine external-qwen3
159 157
 # Без объединения в один документ
160 158
 python -m src.image_ocr -i ./pages/ --ocr-engine surya --no-result-one-document
161 159
 
162
-# С LLM-корректором
163
-python -m src.image_ocr -i page.jpg --ocr-engine paddle --fix-by-llm
160
+# С VLM-корректором
161
+python -m src.image_ocr -i page.jpg --ocr-engine paddle --fix-by-external-qwen3
162
+
163
+# Английский документ (PP-OCRv6 rec)
164
+python -m src.image_ocr -i page.jpg --ocr-engine paddle --main-lang en
164 165
 ```
165 166
 
166 167
 <details>
@@ -188,12 +189,6 @@ python -m src.image_ocr -i page.jpg --ocr-engine paddle --fix-by-llm
188 189
 │                                                        результатов (по       │
189 190
 │                                                        умолчанию — рядом с   │
190 191
 │                                                        входным файлом)       │
191
-│    --fix-by-llm                                        Исправить OCR-ошибки  │
192
-│                                                        локальным LLM         │
193
-│                                                        (Qwen2.5-7B). Не      │
194
-│                                                        работает с            │
195
-│                                                        --ocr-engine          │
196
-│                                                        external-qwen3        │
197 192
 │    --fix-by-external-q…                                Исправить OCR-ошибки  │
198 193
 │                                                        внешней VLM (Qwen3.8  │
199 194
 │                                                        Max, API). Не         │
@@ -289,7 +284,6 @@ python -c "from src.latex.json_to_latex import multi_json_to_latex; \
289 284
 | Движок | Русский текст | Формулы | Скорость (CPU) |
290 285
 |--------|:---:|:---:|:---:|
291 286
 | PaddleOCR (PP-StructV3) | ~85% | ✅ отлично | ~140s |
292
-| + LLM (Qwen2.5-7B) | ~95% | ✅ | +5 min |
293 287
 | + VLM (Qwen3.8 Max) | ~98% | ✅✅ | +10s/блок |
294 288
 | Surya 2 | ~95% | ✅ отлично | ~300s |
295 289
 
@@ -375,7 +369,6 @@ src/
375 369
   postprocess/
376 370
     fixups.py            — OCR-ошибки (VaR)
377 371
     corrector.py         — Corrector protocol
378
-    llm_corrector.py     — Qwen2.5-7B (local)
379 372
     vlm_corrector.py     — Qwen3.8 Max (API)
380 373
   markdown/generator.py  — Markdown + валидация
381 374
   latex/

+ 1
- 5
installation.md 파일 보기

@@ -4,7 +4,7 @@
4 4
 
5 5
 - **OS:** Linux x86_64
6 6
 - **CPU:** 8+ ядер (i7-1165G7 или лучше)
7
-- **RAM:** 16+ GB (32 GB с LLM-корректором)
7
+- **RAM:** 16+ GB
8 8
 - **Диск:** 15+ GB свободного места
9 9
 
10 10
 ---
@@ -65,7 +65,6 @@ cp .env.dist .env
65 65
 |--------|--------|:---:|------|
66 66
 | PaddleOCR | PP-StructureV3 (ансамбль) | ~2.5 GB | `~/.paddlex/official_models/` |
67 67
 | Surya 2 | surya-2.gguf + mmproj | ~1.5 GB | HF Hub (кеш) |
68
-| LLM | Qwen2.5-7B-Q4_K_M.gguf | ~4.4 GB | `~/.cache/llama_models/` |
69 68
 
70 69
 ---
71 70
 
@@ -80,9 +79,6 @@ python -m src.image_ocr -i test.jpg --ocr-engine paddle
80 79
 
81 80
 # Stage 2 — Surya
82 81
 python -m src.image_ocr -i test.jpg --ocr-engine surya
83
-
84
-# Stage 2 — с LLM-корректором
85
-python -m src.image_ocr -i test.jpg --ocr-engine paddle --fix-by-llm
86 82
 ```
87 83
 
88 84
 ---

+ 3
- 5
requirements.txt 파일 보기

@@ -1,6 +1,6 @@
1 1
 # Core
2 2
 typer>=0.15
3
-opencv-python-headless>=4.11
3
+opencv-python-headless>=4.11,<5.0
4 4
 numpy>=1.24,<2.4
5 5
 PyYAML>=6.0
6 6
 Pillow>=11.0
@@ -8,13 +8,11 @@ requests>=2.32
8 8
 beautifulsoup4>=4.12
9 9
 
10 10
 # OCR engines
11
-paddlex>=3.2,<3.4
11
+paddlex[ocr]>=3.7,<3.8
12 12
 paddlepaddle==3.2.2
13
+paddleocr>=3.7
13 14
 surya-ocr>=0.12
14 15
 
15
-# LLM corrector (--fix-by-llm)
16
-llama-cpp-python>=0.3
17
-
18 16
 # Dev
19 17
 pytest>=8.0
20 18
 ruff>=0.9

+ 15
- 22
src/image_ocr.py 파일 보기

@@ -51,14 +51,14 @@ MD_EXTENSION: str = ".md"
51 51
 IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
52 52
 
53 53
 
54
-def _get_ocr_engine(name: str):
54
+def _get_ocr_engine(name: str, main_lang: str):
55 55
     if name == "surya":
56 56
         from src.ocr.surya_engine import surya_ocr_image
57 57
         return surya_ocr_image, "Surya 2 VLM"
58 58
     if name == "external-qwen3":
59 59
         from src.ocr.external_vlm_engine import external_vlm_ocr_image
60 60
         return external_vlm_ocr_image, "Qwen3.8 Max (external VLM)"
61
-    return ocr_image, "PP-StructureV3"
61
+    return functools.partial(ocr_image, main_lang=main_lang), "PP-StructureV3"
62 62
 
63 63
 
64 64
 def _merge_markdown_files(output_dir: Path, md_files: list[Path]) -> None:
@@ -75,7 +75,7 @@ def _merge_markdown_files(output_dir: Path, md_files: list[Path]) -> None:
75 75
 
76 76
 def _process_single(
77 77
     ocr_fn, input_path: Path,
78
-    output_dir_resolved: Path, use_llm: bool, use_vlm: bool,
78
+    output_dir_resolved: Path, use_vlm: bool,
79 79
 ) -> None:
80 80
     """Обрабатывает одно изображение: OCR → fixups → корректоры → JSON + Markdown."""
81 81
     # Этап OCR — самое долгое и хрупкое место
@@ -90,22 +90,15 @@ def _process_single(
90 90
     fix_ocr_errors(page.blocks)
91 91
     logger.debug("Fixups: завершено")
92 92
 
93
-    # Опциональная коррекция через VLM / локальный LLM
94
-    if use_vlm or use_llm:
93
+    # Опциональная коррекция через VLM
94
+    if use_vlm:
95 95
         from src.postprocess.corrector import apply_corrector
96 96
         logger.debug("Корректоры: инициализация")
97
-
98
-    if use_vlm:
99 97
         from src.postprocess.vlm_corrector import vlm_correct_block
100 98
         corrector = functools.partial(vlm_correct_block, image_path=str(input_path.resolve()))
101 99
         apply_corrector(page.blocks, corrector, "VLM")
102 100
         logger.debug("VLM: завершено")
103 101
 
104
-    if use_llm:
105
-        from src.postprocess.llm_corrector import llm_correct_block
106
-        apply_corrector(page.blocks, llm_correct_block, "LLM")
107
-        logger.debug("LLM: завершено")
108
-
109 102
     # Сохранение JSON (всегда) + Markdown (всегда)
110 103
     json_path = output_dir_resolved / (input_path.stem + ".json")
111 104
     if not json_path.exists():
@@ -155,13 +148,6 @@ def image_to_latex(
155 148
             help="Каталог для сохранения результатов (по умолчанию — рядом с входным файлом)",
156 149
         ),
157 150
     ] = None,
158
-    use_llm: Annotated[
159
-        bool,
160
-        typer.Option(
161
-            "--fix-by-llm",
162
-            help="Исправить OCR-ошибки локальным LLM (Qwen2.5-7B). Не работает с --ocr-engine external-qwen3",
163
-        ),
164
-    ] = False,
165 151
     use_vlm: Annotated[
166 152
         bool,
167 153
         typer.Option(
@@ -169,6 +155,13 @@ def image_to_latex(
169 155
             help="Исправить OCR-ошибки внешней VLM (Qwen3.8 Max, API). Не работает с --ocr-engine external-qwen3",
170 156
         ),
171 157
     ] = False,
158
+    main_lang: Annotated[
159
+        str,
160
+        typer.Option(
161
+            "--main-lang",
162
+            help="Основной язык: ru (eslav_PP-OCRv5_mobile_rec) или en (PP-OCRv6_medium_rec)",
163
+        ),
164
+    ] = "ru",
172 165
     result_one_document: Annotated[
173 166
         bool,
174 167
         typer.Option(
@@ -187,7 +180,7 @@ def image_to_latex(
187 180
     ] = 5,
188 181
 ) -> None:
189 182
     """OCR → PostProcess → Markdown. Принимает файл или каталог изображений."""
190
-    ocr_fn, engine_name = _get_ocr_engine(ocr_engine)
183
+    ocr_fn, engine_name = _get_ocr_engine(ocr_engine, main_lang)
191 184
 
192 185
     if input.is_dir():
193 186
         image_paths = sorted(
@@ -223,7 +216,7 @@ def image_to_latex(
223 216
 
224 217
         for i, img_path in enumerate(image_paths):
225 218
             logger.info("[%d/%d] %s...", i + 1 + skipped, len(image_paths) + skipped, img_path.name)
226
-            _process_single(ocr_fn, img_path, out, use_llm, use_vlm)
219
+            _process_single(ocr_fn, img_path, out, use_vlm)
227 220
             if i < len(image_paths) - 1 and pause > 0:
228 221
                 time.sleep(pause)
229 222
 
@@ -241,7 +234,7 @@ def image_to_latex(
241 234
     else:
242 235
         out = output_dir.resolve() if output_dir else input.resolve().parent
243 236
         out.mkdir(parents=True, exist_ok=True)
244
-        _process_single(ocr_fn, input, out, use_llm, use_vlm)
237
+        _process_single(ocr_fn, input, out, use_vlm)
245 238
 
246 239
 
247 240
 def main() -> None:

+ 7
- 2
src/latex/json_to_latex.py 파일 보기

@@ -137,10 +137,12 @@ def _paddle_to_body(data: dict) -> str:
137 137
 
138 138
         if label == "formula":
139 139
             lines.append(f"\\[\n{content}\n\\]\n")
140
+        elif label == "table" and "<table" in content:
141
+            soup = BeautifulSoup(content, "html.parser")
142
+            lines.append(_table_to_latex(soup))
143
+            lines.append("")
140 144
         elif label in ("paragraph_title", "title", "section_header"):
141 145
             lines.append(f"\\section*{{{_escape_latex(content)}}}")
142
-        elif label == "text":
143
-            lines.append(f"\n{_escape_latex(content)}\n")
144 146
         else:
145 147
             lines.append(f"\n{_escape_latex(content)}\n")
146 148
     return "\n".join(lines)
@@ -289,6 +291,9 @@ def _json_block_to_html(block: dict, fmt: str) -> str:
289 291
         label = block.get("block_label", "text")
290 292
         if label == "formula":
291 293
             return f'\n<div class="equation">$$\n{content}\n$$</div>'
294
+        if label == "table" and "<table" in content:
295
+            soup = BeautifulSoup(content, "html.parser")
296
+            return str(soup.find("table"))
292 297
         if label in ("paragraph_title", "title", "section_header"):
293 298
             return f"<h2>{content}</h2>"
294 299
         return f"<p>{content}</p>"

+ 18
- 11
src/ocr/paddle_engine.py 파일 보기

@@ -10,23 +10,30 @@ from paddlex import create_pipeline
10 10
 
11 11
 from src.models import OcrPageResult, ParsedBlock
12 12
 
13
+_REC_MODELS: dict[str, str] = {
14
+    "ru": "eslav_PP-OCRv5_mobile_rec",
15
+    "en": "PP-OCRv6_medium_rec",
16
+}
13 17
 
14
-def _load_config() -> dict[str, Any]:
18
+
19
+def _load_config(main_lang: str) -> dict[str, Any]:
15 20
     ref = importlib.resources.files("paddlex") / "configs" / "pipelines" / "PP-StructureV3.yaml"
16 21
     with importlib.resources.as_file(ref) as path, open(path) as f:
17 22
         config: dict[str, Any] = yaml.safe_load(f)
18 23
 
19
-    config["SubPipelines"]["GeneralOCR"]["SubModules"]["TextRecognition"]["model_name"] = (
20
-        "eslav_PP-OCRv5_mobile_rec"
24
+    rec_model = _REC_MODELS.get(main_lang, _REC_MODELS["ru"])
25
+    config["SubPipelines"]["GeneralOCR"]["SubModules"]["TextDetection"]["model_name"] = (
26
+        "PP-OCRv6_medium_det"
21 27
     )
28
+    config["SubPipelines"]["GeneralOCR"]["SubModules"]["TextRecognition"]["model_name"] = rec_model
22 29
     table_ocr = config["SubPipelines"]["TableRecognition"]["SubPipelines"]["GeneralOCR"]
23
-    table_ocr["SubModules"]["TextRecognition"]["model_name"] = "eslav_PP-OCRv5_mobile_rec"
30
+    table_ocr["SubModules"]["TextRecognition"]["model_name"] = rec_model
24 31
     return config
25 32
 
26 33
 
27 34
 class OcrEngine:
28
-    def __init__(self) -> None:
29
-        self._config: dict[str, Any] = _load_config()
35
+    def __init__(self, main_lang: str) -> None:
36
+        self._config: dict[str, Any] = _load_config(main_lang)
30 37
         self._pipeline: Any = None
31 38
 
32 39
     def process(self, image_path: str) -> OcrPageResult:
@@ -66,10 +73,10 @@ class OcrEngine:
66 73
         return blocks, raw_json
67 74
 
68 75
 
69
-@functools.lru_cache(maxsize=1)
70
-def _get_engine() -> OcrEngine:
71
-    return OcrEngine()
76
+@functools.lru_cache(maxsize=2)
77
+def _get_engine(main_lang: str) -> OcrEngine:
78
+    return OcrEngine(main_lang)
72 79
 
73 80
 
74
-def ocr_image(image_path: str) -> OcrPageResult:
75
-    return _get_engine().process(image_path)
81
+def ocr_image(image_path: str, main_lang: str = "ru") -> OcrPageResult:
82
+    return _get_engine(main_lang).process(image_path)

+ 0
- 44
src/postprocess/llm_corrector.py 파일 보기

@@ -1,44 +0,0 @@
1
-from __future__ import annotations
2
-
3
-import functools
4
-import os
5
-
6
-from llama_cpp import Llama
7
-
8
-from src.models import ParsedBlock
9
-
10
-DEFAULT_LLM_PATH = os.path.expanduser("~/.cache/llama_models/Qwen2.5-7B-Instruct-Q4_K_M.gguf")
11
-
12
-
13
-class LlmCorrector:
14
-    def __init__(self, model_path: str = DEFAULT_LLM_PATH) -> None:
15
-        self._llm = Llama(
16
-            model_path=model_path,
17
-            n_ctx=1024,
18
-            n_threads=8,
19
-            verbose=False,
20
-        )
21
-
22
-    def __call__(self, block: ParsedBlock) -> None:
23
-        if len(block.content) < 30:
24
-            return
25
-
26
-        text = block.content[:400]
27
-        messages = [
28
-            {"role": "system", "content": "Ты корректор OCR-ошибок. Возвращай ТОЛЬКО исправленный текст."},
29
-            {"role": "user", "content": f"Исправь:\n\n{text}"},
30
-        ]
31
-        response = self._llm.create_chat_completion(messages=messages, max_tokens=150, temperature=0.0)
32
-        corrected = response["choices"][0]["message"]["content"].strip()
33
-
34
-        if corrected and len(corrected) > 10 and corrected != text:
35
-            block.content = corrected
36
-
37
-
38
-@functools.lru_cache(maxsize=1)
39
-def _get_llm_corrector() -> LlmCorrector:
40
-    return LlmCorrector()
41
-
42
-
43
-def llm_correct_block(block: ParsedBlock) -> None:
44
-    _get_llm_corrector()(block)

+ 2
- 0
src/split/slicer.py 파일 보기

@@ -86,6 +86,8 @@ def save_page(image: np.ndarray, path: Path) -> None:
86 86
 def generate_output_paths(input_path: Path, page_count: int, output_dir: Path) -> list[Path]:
87 87
     stem = input_path.stem
88 88
     suffix = input_path.suffix.lower()
89
+    if page_count == 1:
90
+        return [output_dir / f"{stem}{suffix}"]
89 91
     max_digits = max(PAGE_NUMBER_WIDTH, len(str(page_count)))
90 92
     return [
91 93
         output_dir / f"{stem}_{i:0{max_digits}d}{suffix}"

+ 131
- 53
stage2_results.md 파일 보기

@@ -33,39 +33,57 @@
33 33
 ### 4. PP-OCRv5_server_rec / PP-OCRv6
34 34
 **Результат:** ❌ не поддерживают русский.
35 35
 - Server model: только CN/JP/EN
36
-- PP-OCRv6: 52 латинских языка
36
+- PP-OCRv6: 46 латинских языков + CN/JP/EN (без кириллицы)
37 37
 - **Вывод:** русский — только mobile-версии
38 38
 
39
-### 5. Локальный LLM-корректор (Qwen2.5-7B GGUF)
40
-**Результат:** +25% читаемости текста.
39
+### 5. PP-OCRv6 детектор (RepLKFPN, 62MB) ✅ оставлен
40
+**Результат:** +4.6% detection accuracy vs v5, на 7% быстрее (97.8s vs 104.7s).
41
+- Модель скачана через paddlex[ocr] 3.7.2 без апгрейда paddlepaddle
42
+- Смешан с eslav_rec — детектор язык-независим
43
+- Совместим с PP-StructureV3 через YAML-конфиг
44
+- **Вывод:** бесплатный прирост детекции без риска
45
+
46
+### 6. `--main-lang` выбор распознавалки ✅ оставлен
47
+**Результат:** переключение rec-модели по основному языку страницы.
48
+- `--main-lang=ru` → `eslav_PP-OCRv5_mobile_rec` (русский, 85-90%)
49
+- `--main-lang=en` → `PP-OCRv6_medium_rec` (английский + греческие в формулах, 75-80%)
50
+- Таблицы и формулы — общие (SLANet + PP-FormulaNet)
51
+- Модели кешируются раздельно (lru_cache по языку)
52
+- **Вывод:** +15-20% на английских страницах заменой одной модели
53
+
54
+### 7. Локальный LLM-корректор (Qwen2.5-7B Q4_K_M) ❌ удалён
55
+**Результат:** 50/50 — исправляет и тут же галлюцинирует.
41 56
 ```
42
-До:   t - номер месяцаB дефолте, на который прогнозируются возвраты
43
-После: т — номер месяца в диапазоне между текущим сроком в дефолте и горизонтом взыскания
57
+До:   сypporat WoE для гpynnы i
58
+После: коррелят weight of evidence (галлюцинация вместо «суррогат»)
44 59
 ```
45
-- ~4 tok/sec на CPU, ~20s/блок
46
-- **Вывод:** эффективен для пост-обработки
60
+- Причины отказа: (1) слепой — не видит изображение, догадывается; (2) маленький — 7B квантизованный не понимает контекст; (3) избыточный — `fixups.py` уже правит VaR/V^2 без модели
61
+- Занятые ресурсы: 4.4 GB диск + 5 GB RAM + 60s/блок
62
+- **Вывод:** удалён, освобождено 4.4 GB
47 63
 
48
-### 6. VLM-корректор (Qwen3.8 Max через OpenCode API)
64
+### 8. VLM-корректор (Qwen3.8 Max через OpenCode API)
49 65
 **Результат:** полное восстановление текста + авто inline-формулы.
50 66
 ```
51 67
 До:   ΣДсе, - дисконтированная сумма возвратов
52 68
 После: $\sum_t DCF_{i,t}$ — дисконтированная сумма возвратов
53 69
 ```
54 70
 - ~10s/блок, Anthropic API `zen/go/v1/messages`
71
+- Таблицы: HTML → tab-separated flat text ✅
72
+- На порядок качественнее LLM — видит изображение, читает напрямую
55 73
 - **Вывод:** лучшее качество, требует API-доступа
56 74
 
57
-### 7. Docling (IBM Research)
75
+### 9. Docling (IBM Research)
58 76
 **Результат:** ❌ не поддерживает PaddleOCR как OCR-backend.
59 77
 - Поддерживает: EasyOCR, Tesseract, RapidOCR, Nemotron
60 78
 - **Вывод:** несовместим с нашим пайплайном
61 79
 
62
-### 8. PaddleOCR-VL (ERNIE-4.5-0.3B)
80
+### 10. PaddleOCR-VL (ERNIE-4.5-0.3B)
63 81
 **Результат:** модель загружена (1.79 GB), ~15 мин/стр на CPU.
64 82
 - Одна VLM вместо ансамбля
65 83
 - Теоретически решает проблему русского (LM понимает контекст)
66 84
 - **Вывод:** правильный подход при GPU, нереализуем на CPU
67 85
 
68
-### 9. Surya 2 (Datalab, 650M VLM) ⭐
86
+### 11. Surya 2 (Datalab, 650M VLM) ⭐
69 87
 **Результат:** лучший русский OCR из коробки — но медленно на CPU.
70 88
 - 95%+ точность русского текста без коррекции
71 89
 - Авто inline-формулы: `<math>MRec_{it}</math>` → `$MRec_{it}$`
@@ -73,7 +91,7 @@
73 91
 - **Слабость:** таблицы требуют много токенов → таймауты на CPU
74 92
 - **Вывод:** лучший выбор по качеству, медленнее на CPU
75 93
 
76
-### 10. VLM full-page (Qwen3.8 Max) для таблиц
94
+### 12. VLM full-page (Qwen3.8 Max) для таблиц
77 95
 **Результат:** 118s на страницу с таблицей, качество отличное.
78 96
 - Таблица → pipe-Markdown: `| col1 | col2 |`
79 97
 - Формулы → `$...$`
@@ -82,37 +100,97 @@
82 100
 
83 101
 ---
84 102
 
103
+## Эксперименты по препроцессингу изображений
104
+
105
+### 13. Denoising (Non-Local Means) ✅ оставлен
106
+**Результат:** 17 блоков vs 12 без (+42%), исправил «6»→«G», «Пр»→«р».
107
+- Без denoise: «рогнозное», «L6D ID», 12 блоков, 104.7s
108
+- С denoise: «Прогнозное», «LGD ID», 17 блоков, 139.5s (+33% времени)
109
+- **Вывод:** +42% блоков за +35s — оставлен как опция `--denoise`
110
+
111
+### 14. Адаптивная бинаризация (Otsu/Adaptive) ❌ удалена
112
+**Результат:** жёсткие границы порвали буквы, ложные контуры.
113
+- «рассчитывается» → «рассчита**встся**»
114
+- Формулы: `$b_i$` → `$b_{-}i-\\sec a$` (мусор)
115
+- Мусорные блоки: `$\alpha\times\alpha\times\alpha...$` — артефакты бинаризации
116
+- **Причина:** нейросетевой OCR обучен на естественных изображениях, не на бинарных
117
+- **Вывод:** удалена из CLI и пайплайна
118
+
119
+### 15. Upscale ×2 для мелкого текста ❌ удалён
120
+**Результат:** бесполезен — PaddleOCR режет `max_side_limit=4000`.
121
+- Изображение 6090×3880 (×2) → Paddle сжимает обратно до ≤4000
122
+- Время впустую: 174s вместо 104s
123
+- **Причина:** параметр `max_side_limit` жёстко ограничивает входное разрешение
124
+- **Вывод:** удалён из CLI, требует правки `max_side_limit` в YAML для пользы
125
+
126
+### 16. Document Unwarping (Canny → 4-угольник → perspective) ✅ оставлен
127
+**Результат:** работает для фото под углом, NOP для плоского сканера.
128
+- Планшетный сканер: контур страницы не найден → возврат исходника
129
+- Фото: детектит 4-угольник → warp в прямоугольник
130
+- Баг первой версии: детектил внутренние таблицы (bbox ~150px высотой)
131
+- **Исправление:** порог `min_area ≥ 30%` от изображения
132
+- **Вывод:** опция `--unwarp` для фото, для сканера — YAGNI
133
+
134
+---
135
+
136
+## Оптимизации кода (SOLID / DRY / KISS / YAGNI)
137
+
138
+| Действие | Результат |
139
+|---|---|
140
+| `ParsedBlock`/`OcrPageResult` → `src/models.py` | 7 модулей отвязаны от paddle_engine |
141
+| `Corrector(Protocol)` → `Callable` | Удалён неиспользуемый Protocol |
142
+| `validate_output_dir()` удалён | Мёртвый код из `slicer.py` |
143
+| `_ensure_env()` → `load_env()` напрямую | Убраны дублирующие guard'ы |
144
+| `calc_scale_dims()` в `image_utils.py` | Общий pre-scale для Surya + external VLM |
145
+| `functools.lru_cache` для engine-синглтонов | Убран boilerplate `_engine: X \| None` |
146
+| `surya2html.py`, `tex2html.py` удалены | Дубликаты логики из `json_to_latex.py` |
147
+| `binarize_image()`, `upscale_image()` удалены | Мёртвые функции из `slicer.py` |
148
+| `requirements.txt`: 118 → 14 пакетов | Только прямые зависимости |
149
+| `llm_corrector.py` + Qwen2.5-7B (4.4GB) удалены | Галлюцинации, YAGNI |
150
+
151
+---
152
+
85 153
 ## Итоговая архитектура
86 154
 
87 155
 ```
88 156
 Входное изображение (JPEG/PNG/TIFF)
89 157
90 158
91
-src/image_prepare.py          ← Stage 1: разрезание
159
+src/image_prepare.py          ← Stage 1: подготовка
92 160
     │ --slice / --slice-auto
93
-    │ --border, --post-crop
161
+    │ --pre-rotate, --border
162
+    │ --unwarp, --denoise
163
+    │ --post-crop
94 164
95 165
96 166
 src/image_ocr.py            ← Stage 2: OCR + Markdown
97
-    │ --ocr-engine paddle|surya
98
-    │ --llm, --vlm
99
-    │ --force-ocr, --pause
100
-    │ --result-one-document (ON по умолчанию)
167
+    │ --ocr-engine paddle|surya|external-qwen3
168
+    │ --main-lang ru|en
169
+    │ --fix-by-external-qwen3
170
+    │ --pause, --no-result-one-document
101 171
102 172
     ├─ PaddleEngine / SuryaEngine   (OCR)
103 173
     ├─ fix_ocr_errors               (VaR, V^2)
104
-    ├─ LlmCorrector                 (Qwen2.5-7B, local)
105 174
     ├─ VlmCorrector                 (Qwen3.8 Max, API)
106 175
107 176
108 177
 .md + .json (per page) + merged.md
178
+     │
179
+     ▼
180
+src/latex/json_to_latex.py  ← Stage 3: JSON → LaTeX + HTML
181
+    │ Paddle / Surya / Qwen
182
+    │ Автоопределение формата
183
+    │
184
+    ▼
185
+.tex + .html (с MathJax для таблиц и формул)
109 186
 ```
110 187
 
111 188
 ### По умолчанию включено
112 189
 
113 190
 | Функция | Статус |
114 191
 |---------|:------:|
115
-| `--resume` (пропуск JSON) | ON |
192
+| `--main-lang ru` | ON |
193
+| Resume (пропуск JSON) | ON |
116 194
 | `--result-one-document` | ON |
117 195
 | `--pause 5` | ON |
118 196
 | `--post-crop` (Stage 1) | ON |
@@ -121,23 +199,25 @@ src/image_ocr.py            ← Stage 2: OCR + Markdown
121 199
 
122 200
 ```
123 201
 src/
124
-  image_prepare.py         — CLI Stage 1
125
-  image_ocr.py           — CLI Stage 2
126
-  cli_utils.py           — run_main()
127
-  env.py                 — загрузка .env
128
-  split/slicer.py        — разрезание, post-crop, border
202
+  models.py               — ParsedBlock, OcrPageResult
203
+  image_utils.py          — calc_scale_dims
204
+  image_prepare.py        — CLI Stage 1
205
+  image_ocr.py            — CLI Stage 2
206
+  cli_utils.py            — run_main()
207
+  env.py                  — загрузка .env
208
+  split/slicer.py         — разрезание, post-crop, border, unwarp, denoise
129 209
   ocr/
130
-    paddle_engine.py     — PP-StructureV3 + кастом YAML (eslav)
131
-    surya_engine.py      — Surya 2 + CPU-оптимизации + health_check
210
+    paddle_engine.py      — PP-StructureV3 + v6-det + ru/en rec
211
+    surya_engine.py       — Surya 2 + CPU-оптимизации + health_check
212
+    external_vlm_engine.py — Qwen3.8 Max full-page OCR
132 213
   postprocess/
133
-    fixups.py            — VaR, V^2
134
-    corrector.py         — Corrector Protocol
135
-    llm_corrector.py     — Qwen2.5-7B локально
136
-    vlm_corrector.py     — Qwen3.8 Max API (Anthropic)
137
-  markdown/generator.py  — Markdown + валидация LaTeX
214
+    fixups.py             — VaR, V^2
215
+    corrector.py          — apply_corrector
216
+    vlm_corrector.py      — Qwen3.8 Max per-block API
217
+    qwen_client.py        — общий Qwen API-клиент
218
+  markdown/generator.py   — Markdown + валидация LaTeX
138 219
   latex/
139
-    json_to_latex.py     — Единый JSON→LaTeX (Paddle + Surya)
140
-    json_to_latex.py     — Единый JSON→LaTeX+HTML (Paddle + Surya + Qwen)
220
+    json_to_latex.py      — JSON→LaTeX+HTML (Paddle + Surya + Qwen)
141 221
 ```
142 222
 
143 223
 ---
@@ -153,12 +233,6 @@ src/
153 233
 | Threads | default | `-t 8` | стабильно |
154 234
 | **Итого** | **430s** | **300s** | **-30%** |
155 235
 
156
-### Health-check + retry
157
-```
158
-llama-server не отвечает → logger.warning → перезапуск SuryaEngine
159
-→ повторный health_check → OK / RuntimeError с инструкцией
160
-```
161
-
162 236
 ---
163 237
 
164 238
 ## Сравнение движков
@@ -166,11 +240,12 @@ llama-server не отвечает → logger.warning → перезапуск S
166 240
 | Параметр | PaddleOCR | Surya 2 | VLM Qwen3.8 |
167 241
 |----------|:---:|:---:|:---:|
168 242
 | Архитектура | Ансамбль 5 моделей | Один VLM (650M) | Cloud VLM |
169
-| Русский текст | ~85% | 95%+ | 98%+ |
243
+| Русский текст | ~85% (eslav rec) | 95%+ | 98%+ |
244
+| Английский текст | ~80% (v6 rec) | 95%+ | 98%+ |
170 245
 | Формулы | PP-FormulaNet | VLM (отлично) | VLM (отлично) |
171 246
 | Таблицы | PP-TableMagic + bbox | ❌ CPU timeout | ✅ Pipe-Markdown |
172 247
 | Inline-формулы | Нет | `<math>` тэги | `$...$` |
173
-| Скорость CPU | ~140s | ~300s | ~120s (API) |
248
+| Скорость CPU | ~100-160s | ~300s | ~120s (API) |
174 249
 | Офлайн | ✅ | ✅ | ❌ |
175 250
 | Размер моделей | ~2.5 GB | ~1.5 GB | 0 (cloud) |
176 251
 
@@ -180,15 +255,15 @@ llama-server не отвечает → logger.warning → перезапуск S
180 255
 
181 256
 | Этап | PaddleOCR | Surya 2 | VLM |
182 257
 |------|:---:|:---:|:---:|
183
-| OCR (одна страница) | 140s | 300s | 120s |
184
-| LLM-корректор | +5 min | не нужен | не нужен |
258
+| OCR (одна страница) | 100-160s | 300s | 120s |
259
+| Denoise (Stage 1) | +35s | — | — |
185 260
 | VLM-корректор | +10s/блок | не нужен | — |
186 261
 | JSON + MD сохранение | <1s | <1s | <1s |
187 262
 | Merge в один документ | <1s | <1s | <1s |
188
-| **Итого на страницу** | ~5 min | ~5 min | ~2 min |
189
-| **12 страниц (batch)** | ~28 min* | ~60 min | ~24 min |
263
+| **Итого на страницу** | ~3-4 min | ~5 min | ~2 min |
264
+| **12 страниц (batch)** | ~15-20 min* | ~60 min | ~24 min |
190 265
 
191
-\* PaddleOCR с `--pause 5`
266
+\* PaddleOCR с `--pause 5` и `--denoise`
192 267
 
193 268
 ---
194 269
 
@@ -203,9 +278,12 @@ llama-server не отвечает → logger.warning → перезапуск S
203 278
 
204 279
 ## Выводы
205 280
 
206
-1. **PP-StructureV3** — надёжен для структуры и формул, русский текст требует LLM/VLM коррекции
207
-2. **Surya 2** — лучший русский OCR из коробки, CPU-оптимизации дали -30% времени
208
-3. **VLM Qwen3.8 Max** — решает таблицы там, где Surya падает, при приемлемой скорости
209
-4. **Гибридный подход**: Surya для текста + VLM для таблиц = лучший баланс
210
-5. **LLM/VLM коррекция** эффективна для PaddleOCR, не нужна для Surya 2
211
-6. **CPU-ограничения** — основной фактор, GPU сделал бы Surya/PaddleOCR-VL идеальным выбором
281
+1. **PP-StructureV3 + v6-det + eslav_rec** — лучший офлайн OCR для русского на CPU: 85-90% текст, 90% формулы, 80% таблицы
282
+2. **PP-OCRv6 rec** через `--main-lang=en` — +20% на английских страницах
283
+3. **Denoise** — +42% блоков на сканах с шумом, оставлен как опция
284
+4. **Бинаризация, upscale** — вредны для нейросетевого OCR, удалены
285
+5. **Локальный LLM (Qwen2.5-7B)** — галлюцинирует, удалён
286
+6. **VLM-корректор (Qwen3.8 Max)** — отличный, но API
287
+7. **Surya 2** — лучший русский OCR из коробки, CPU-оптимизации дали -30% времени
288
+8. **Гибридный подход**: Surya для текста + VLM для таблиц = лучший баланс
289
+9. **CPU-ограничения** — основной фактор, GPU сделал бы Surya/PaddleOCR-VL идеальным выбором

+ 1
- 1
tests/test_post_crop.py 파일 보기

@@ -171,7 +171,7 @@ class TestSkipEmptyPages:
171 171
         with tempfile.TemporaryDirectory() as tmp:
172 172
             result = process_image(Path(path), 1, 2, Path(tmp), border_px=5)
173 173
             assert len(result) == 1
174
-            assert result[0].name == Path(path).stem + "_01.png"
174
+            assert result[0].name == Path(path).stem + ".png"
175 175
 
176 176
     def test_all_empty_returns_empty_list(self) -> None:
177 177
         image = create_test_image(200, 200)

+ 1
- 1
tests/test_slicer.py 파일 보기

@@ -226,7 +226,7 @@ class TestGenerateOutputPaths:
226 226
 
227 227
     def test_png_extension(self) -> None:
228 228
         paths = generate_output_paths(Path("img.PNG"), 1, Path("out"))
229
-        assert paths == [Path("out/img_01.png")]
229
+        assert paths == [Path("out/img.png")]
230 230
 
231 231
     def test_zero_padding(self) -> None:
232 232
         paths = generate_output_paths(Path("scan.jpg"), 12, Path("out"))

Loading…
취소
저장