Bläddra i källkod

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

master
Evgeniy Ierusalimov 3 dagar sedan
förälder
incheckning
a154a1627f

+ 6
- 13
README.md Visa fil

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

+ 1
- 5
installation.md Visa fil

4
 
4
 
5
 - **OS:** Linux x86_64
5
 - **OS:** Linux x86_64
6
 - **CPU:** 8+ ядер (i7-1165G7 или лучше)
6
 - **CPU:** 8+ ядер (i7-1165G7 или лучше)
7
-- **RAM:** 16+ GB (32 GB с LLM-корректором)
7
+- **RAM:** 16+ GB
8
 - **Диск:** 15+ GB свободного места
8
 - **Диск:** 15+ GB свободного места
9
 
9
 
10
 ---
10
 ---
65
 |--------|--------|:---:|------|
65
 |--------|--------|:---:|------|
66
 | PaddleOCR | PP-StructureV3 (ансамбль) | ~2.5 GB | `~/.paddlex/official_models/` |
66
 | PaddleOCR | PP-StructureV3 (ансамбль) | ~2.5 GB | `~/.paddlex/official_models/` |
67
 | Surya 2 | surya-2.gguf + mmproj | ~1.5 GB | HF Hub (кеш) |
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
 
79
 
81
 # Stage 2 — Surya
80
 # Stage 2 — Surya
82
 python -m src.image_ocr -i test.jpg --ocr-engine surya
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 Visa fil

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

+ 15
- 22
src/image_ocr.py Visa fil

51
 IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
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
     if name == "surya":
55
     if name == "surya":
56
         from src.ocr.surya_engine import surya_ocr_image
56
         from src.ocr.surya_engine import surya_ocr_image
57
         return surya_ocr_image, "Surya 2 VLM"
57
         return surya_ocr_image, "Surya 2 VLM"
58
     if name == "external-qwen3":
58
     if name == "external-qwen3":
59
         from src.ocr.external_vlm_engine import external_vlm_ocr_image
59
         from src.ocr.external_vlm_engine import external_vlm_ocr_image
60
         return external_vlm_ocr_image, "Qwen3.8 Max (external VLM)"
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
 def _merge_markdown_files(output_dir: Path, md_files: list[Path]) -> None:
64
 def _merge_markdown_files(output_dir: Path, md_files: list[Path]) -> None:
75
 
75
 
76
 def _process_single(
76
 def _process_single(
77
     ocr_fn, input_path: Path,
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
 ) -> None:
79
 ) -> None:
80
     """Обрабатывает одно изображение: OCR → fixups → корректоры → JSON + Markdown."""
80
     """Обрабатывает одно изображение: OCR → fixups → корректоры → JSON + Markdown."""
81
     # Этап OCR — самое долгое и хрупкое место
81
     # Этап OCR — самое долгое и хрупкое место
90
     fix_ocr_errors(page.blocks)
90
     fix_ocr_errors(page.blocks)
91
     logger.debug("Fixups: завершено")
91
     logger.debug("Fixups: завершено")
92
 
92
 
93
-    # Опциональная коррекция через VLM / локальный LLM
94
-    if use_vlm or use_llm:
93
+    # Опциональная коррекция через VLM
94
+    if use_vlm:
95
         from src.postprocess.corrector import apply_corrector
95
         from src.postprocess.corrector import apply_corrector
96
         logger.debug("Корректоры: инициализация")
96
         logger.debug("Корректоры: инициализация")
97
-
98
-    if use_vlm:
99
         from src.postprocess.vlm_corrector import vlm_correct_block
97
         from src.postprocess.vlm_corrector import vlm_correct_block
100
         corrector = functools.partial(vlm_correct_block, image_path=str(input_path.resolve()))
98
         corrector = functools.partial(vlm_correct_block, image_path=str(input_path.resolve()))
101
         apply_corrector(page.blocks, corrector, "VLM")
99
         apply_corrector(page.blocks, corrector, "VLM")
102
         logger.debug("VLM: завершено")
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
     # Сохранение JSON (всегда) + Markdown (всегда)
102
     # Сохранение JSON (всегда) + Markdown (всегда)
110
     json_path = output_dir_resolved / (input_path.stem + ".json")
103
     json_path = output_dir_resolved / (input_path.stem + ".json")
111
     if not json_path.exists():
104
     if not json_path.exists():
155
             help="Каталог для сохранения результатов (по умолчанию — рядом с входным файлом)",
148
             help="Каталог для сохранения результатов (по умолчанию — рядом с входным файлом)",
156
         ),
149
         ),
157
     ] = None,
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
     use_vlm: Annotated[
151
     use_vlm: Annotated[
166
         bool,
152
         bool,
167
         typer.Option(
153
         typer.Option(
169
             help="Исправить OCR-ошибки внешней VLM (Qwen3.8 Max, API). Не работает с --ocr-engine external-qwen3",
155
             help="Исправить OCR-ошибки внешней VLM (Qwen3.8 Max, API). Не работает с --ocr-engine external-qwen3",
170
         ),
156
         ),
171
     ] = False,
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
     result_one_document: Annotated[
165
     result_one_document: Annotated[
173
         bool,
166
         bool,
174
         typer.Option(
167
         typer.Option(
187
     ] = 5,
180
     ] = 5,
188
 ) -> None:
181
 ) -> None:
189
     """OCR → PostProcess → Markdown. Принимает файл или каталог изображений."""
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
     if input.is_dir():
185
     if input.is_dir():
193
         image_paths = sorted(
186
         image_paths = sorted(
223
 
216
 
224
         for i, img_path in enumerate(image_paths):
217
         for i, img_path in enumerate(image_paths):
225
             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)
226
-            _process_single(ocr_fn, img_path, out, use_llm, use_vlm)
219
+            _process_single(ocr_fn, img_path, out, use_vlm)
227
             if i < len(image_paths) - 1 and pause > 0:
220
             if i < len(image_paths) - 1 and pause > 0:
228
                 time.sleep(pause)
221
                 time.sleep(pause)
229
 
222
 
241
     else:
234
     else:
242
         out = output_dir.resolve() if output_dir else input.resolve().parent
235
         out = output_dir.resolve() if output_dir else input.resolve().parent
243
         out.mkdir(parents=True, exist_ok=True)
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
 def main() -> None:
240
 def main() -> None:

+ 7
- 2
src/latex/json_to_latex.py Visa fil

137
 
137
 
138
         if label == "formula":
138
         if label == "formula":
139
             lines.append(f"\\[\n{content}\n\\]\n")
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
         elif label in ("paragraph_title", "title", "section_header"):
144
         elif label in ("paragraph_title", "title", "section_header"):
141
             lines.append(f"\\section*{{{_escape_latex(content)}}}")
145
             lines.append(f"\\section*{{{_escape_latex(content)}}}")
142
-        elif label == "text":
143
-            lines.append(f"\n{_escape_latex(content)}\n")
144
         else:
146
         else:
145
             lines.append(f"\n{_escape_latex(content)}\n")
147
             lines.append(f"\n{_escape_latex(content)}\n")
146
     return "\n".join(lines)
148
     return "\n".join(lines)
289
         label = block.get("block_label", "text")
291
         label = block.get("block_label", "text")
290
         if label == "formula":
292
         if label == "formula":
291
             return f'\n<div class="equation">$$\n{content}\n$$</div>'
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
         if label in ("paragraph_title", "title", "section_header"):
297
         if label in ("paragraph_title", "title", "section_header"):
293
             return f"<h2>{content}</h2>"
298
             return f"<h2>{content}</h2>"
294
         return f"<p>{content}</p>"
299
         return f"<p>{content}</p>"

+ 18
- 11
src/ocr/paddle_engine.py Visa fil

10
 
10
 
11
 from src.models import OcrPageResult, ParsedBlock
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
     ref = importlib.resources.files("paddlex") / "configs" / "pipelines" / "PP-StructureV3.yaml"
20
     ref = importlib.resources.files("paddlex") / "configs" / "pipelines" / "PP-StructureV3.yaml"
16
     with importlib.resources.as_file(ref) as path, open(path) as f:
21
     with importlib.resources.as_file(ref) as path, open(path) as f:
17
         config: dict[str, Any] = yaml.safe_load(f)
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
     table_ocr = config["SubPipelines"]["TableRecognition"]["SubPipelines"]["GeneralOCR"]
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
     return config
31
     return config
25
 
32
 
26
 
33
 
27
 class OcrEngine:
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
         self._pipeline: Any = None
37
         self._pipeline: Any = None
31
 
38
 
32
     def process(self, image_path: str) -> OcrPageResult:
39
     def process(self, image_path: str) -> OcrPageResult:
66
         return blocks, raw_json
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 Visa fil

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 Visa fil

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

+ 131
- 53
stage2_results.md Visa fil

33
 ### 4. PP-OCRv5_server_rec / PP-OCRv6
33
 ### 4. PP-OCRv5_server_rec / PP-OCRv6
34
 **Результат:** ❌ не поддерживают русский.
34
 **Результат:** ❌ не поддерживают русский.
35
 - Server model: только CN/JP/EN
35
 - Server model: только CN/JP/EN
36
-- PP-OCRv6: 52 латинских языка
36
+- PP-OCRv6: 46 латинских языков + CN/JP/EN (без кириллицы)
37
 - **Вывод:** русский — только mobile-версии
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
 **Результат:** полное восстановление текста + авто inline-формулы.
65
 **Результат:** полное восстановление текста + авто inline-формулы.
50
 ```
66
 ```
51
 До:   ΣДсе, - дисконтированная сумма возвратов
67
 До:   ΣДсе, - дисконтированная сумма возвратов
52
 После: $\sum_t DCF_{i,t}$ — дисконтированная сумма возвратов
68
 После: $\sum_t DCF_{i,t}$ — дисконтированная сумма возвратов
53
 ```
69
 ```
54
 - ~10s/блок, Anthropic API `zen/go/v1/messages`
70
 - ~10s/блок, Anthropic API `zen/go/v1/messages`
71
+- Таблицы: HTML → tab-separated flat text ✅
72
+- На порядок качественнее LLM — видит изображение, читает напрямую
55
 - **Вывод:** лучшее качество, требует API-доступа
73
 - **Вывод:** лучшее качество, требует API-доступа
56
 
74
 
57
-### 7. Docling (IBM Research)
75
+### 9. Docling (IBM Research)
58
 **Результат:** ❌ не поддерживает PaddleOCR как OCR-backend.
76
 **Результат:** ❌ не поддерживает PaddleOCR как OCR-backend.
59
 - Поддерживает: EasyOCR, Tesseract, RapidOCR, Nemotron
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
 **Результат:** модель загружена (1.79 GB), ~15 мин/стр на CPU.
81
 **Результат:** модель загружена (1.79 GB), ~15 мин/стр на CPU.
64
 - Одна VLM вместо ансамбля
82
 - Одна VLM вместо ансамбля
65
 - Теоретически решает проблему русского (LM понимает контекст)
83
 - Теоретически решает проблему русского (LM понимает контекст)
66
 - **Вывод:** правильный подход при GPU, нереализуем на CPU
84
 - **Вывод:** правильный подход при GPU, нереализуем на CPU
67
 
85
 
68
-### 9. Surya 2 (Datalab, 650M VLM) ⭐
86
+### 11. Surya 2 (Datalab, 650M VLM) ⭐
69
 **Результат:** лучший русский OCR из коробки — но медленно на CPU.
87
 **Результат:** лучший русский OCR из коробки — но медленно на CPU.
70
 - 95%+ точность русского текста без коррекции
88
 - 95%+ точность русского текста без коррекции
71
 - Авто inline-формулы: `<math>MRec_{it}</math>` → `$MRec_{it}$`
89
 - Авто inline-формулы: `<math>MRec_{it}</math>` → `$MRec_{it}$`
73
 - **Слабость:** таблицы требуют много токенов → таймауты на CPU
91
 - **Слабость:** таблицы требуют много токенов → таймауты на CPU
74
 - **Вывод:** лучший выбор по качеству, медленнее на CPU
92
 - **Вывод:** лучший выбор по качеству, медленнее на CPU
75
 
93
 
76
-### 10. VLM full-page (Qwen3.8 Max) для таблиц
94
+### 12. VLM full-page (Qwen3.8 Max) для таблиц
77
 **Результат:** 118s на страницу с таблицей, качество отличное.
95
 **Результат:** 118s на страницу с таблицей, качество отличное.
78
 - Таблица → pipe-Markdown: `| col1 | col2 |`
96
 - Таблица → pipe-Markdown: `| col1 | col2 |`
79
 - Формулы → `$...$`
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
 Входное изображение (JPEG/PNG/TIFF)
156
 Входное изображение (JPEG/PNG/TIFF)
89
157
90
158
91
-src/image_prepare.py          ← Stage 1: разрезание
159
+src/image_prepare.py          ← Stage 1: подготовка
92
     │ --slice / --slice-auto
160
     │ --slice / --slice-auto
93
-    │ --border, --post-crop
161
+    │ --pre-rotate, --border
162
+    │ --unwarp, --denoise
163
+    │ --post-crop
94
164
95
165
96
 src/image_ocr.py            ← Stage 2: OCR + Markdown
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
     ├─ PaddleEngine / SuryaEngine   (OCR)
172
     ├─ PaddleEngine / SuryaEngine   (OCR)
103
     ├─ fix_ocr_errors               (VaR, V^2)
173
     ├─ fix_ocr_errors               (VaR, V^2)
104
-    ├─ LlmCorrector                 (Qwen2.5-7B, local)
105
     ├─ VlmCorrector                 (Qwen3.8 Max, API)
174
     ├─ VlmCorrector                 (Qwen3.8 Max, API)
106
175
107
176
108
 .md + .json (per page) + merged.md
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
 | `--result-one-document` | ON |
194
 | `--result-one-document` | ON |
117
 | `--pause 5` | ON |
195
 | `--pause 5` | ON |
118
 | `--post-crop` (Stage 1) | ON |
196
 | `--post-crop` (Stage 1) | ON |
121
 
199
 
122
 ```
200
 ```
123
 src/
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
   ocr/
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
   postprocess/
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
   latex/
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
 | Threads | default | `-t 8` | стабильно |
233
 | Threads | default | `-t 8` | стабильно |
154
 | **Итого** | **430s** | **300s** | **-30%** |
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
 | Параметр | PaddleOCR | Surya 2 | VLM Qwen3.8 |
240
 | Параметр | PaddleOCR | Surya 2 | VLM Qwen3.8 |
167
 |----------|:---:|:---:|:---:|
241
 |----------|:---:|:---:|:---:|
168
 | Архитектура | Ансамбль 5 моделей | Один VLM (650M) | Cloud VLM |
242
 | Архитектура | Ансамбль 5 моделей | Один VLM (650M) | Cloud VLM |
169
-| Русский текст | ~85% | 95%+ | 98%+ |
243
+| Русский текст | ~85% (eslav rec) | 95%+ | 98%+ |
244
+| Английский текст | ~80% (v6 rec) | 95%+ | 98%+ |
170
 | Формулы | PP-FormulaNet | VLM (отлично) | VLM (отлично) |
245
 | Формулы | PP-FormulaNet | VLM (отлично) | VLM (отлично) |
171
 | Таблицы | PP-TableMagic + bbox | ❌ CPU timeout | ✅ Pipe-Markdown |
246
 | Таблицы | PP-TableMagic + bbox | ❌ CPU timeout | ✅ Pipe-Markdown |
172
 | Inline-формулы | Нет | `<math>` тэги | `$...$` |
247
 | Inline-формулы | Нет | `<math>` тэги | `$...$` |
173
-| Скорость CPU | ~140s | ~300s | ~120s (API) |
248
+| Скорость CPU | ~100-160s | ~300s | ~120s (API) |
174
 | Офлайн | ✅ | ✅ | ❌ |
249
 | Офлайн | ✅ | ✅ | ❌ |
175
 | Размер моделей | ~2.5 GB | ~1.5 GB | 0 (cloud) |
250
 | Размер моделей | ~2.5 GB | ~1.5 GB | 0 (cloud) |
176
 
251
 
180
 
255
 
181
 | Этап | PaddleOCR | Surya 2 | VLM |
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
 | VLM-корректор | +10s/блок | не нужен | — |
260
 | VLM-корректор | +10s/блок | не нужен | — |
186
 | JSON + MD сохранение | <1s | <1s | <1s |
261
 | JSON + MD сохранение | <1s | <1s | <1s |
187
 | Merge в один документ | <1s | <1s | <1s |
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
 
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 Visa fil

171
         with tempfile.TemporaryDirectory() as tmp:
171
         with tempfile.TemporaryDirectory() as tmp:
172
             result = process_image(Path(path), 1, 2, Path(tmp), border_px=5)
172
             result = process_image(Path(path), 1, 2, Path(tmp), border_px=5)
173
             assert len(result) == 1
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
     def test_all_empty_returns_empty_list(self) -> None:
176
     def test_all_empty_returns_empty_list(self) -> None:
177
         image = create_test_image(200, 200)
177
         image = create_test_image(200, 200)

+ 1
- 1
tests/test_slicer.py Visa fil

226
 
226
 
227
     def test_png_extension(self) -> None:
227
     def test_png_extension(self) -> None:
228
         paths = generate_output_paths(Path("img.PNG"), 1, Path("out"))
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
     def test_zero_padding(self) -> None:
231
     def test_zero_padding(self) -> None:
232
         paths = generate_output_paths(Path("scan.jpg"), 12, Path("out"))
232
         paths = generate_output_paths(Path("scan.jpg"), 12, Path("out"))

Laddar…
Avbryt
Spara