Browse Source

added external OCR VLM Qwen3.8 for whole image OCR

master
Evgeniy Ierusalimov 5 days ago
parent
commit
fc66688d11
5 changed files with 201 additions and 139 deletions
  1. 14
    12
      README.md
  2. 17
    24
      src/image_ocr.py
  3. 70
    0
      src/ocr/external_vlm_engine.py
  4. 83
    0
      src/postprocess/qwen_client.py
  5. 17
    103
      src/postprocess/vlm_corrector.py

+ 14
- 12
README.md View File

@@ -76,30 +76,32 @@ python -m src.image_split -i scan.jpg --slice-auto
76 76
 |----------|----------|:---:|
77 77
 | `--input` / `-i` | Файл изображения **или** каталог | required |
78 78
 | `--output-dir` / `-o` | Каталог вывода | рядом с `--input` |
79
-| `--lang` / `-l` | Язык OCR | `en` |
80
-| `--ocr-engine` | `paddle` или `surya` | `paddle` |
81
-| `--llm` | Локальный LLM-корректор (Qwen2.5-7B) | OFF |
82
-| `--vlm` | Vision LM корректор (Qwen3.8 Max, API) | OFF |
79
+| `--ocr-engine` | **Обязательный:** `paddle`, `surya` или `external-qwen3` | — |
80
+| `--fix-by-llm` | Исправить ошибки локальным LLM (Qwen2.5-7B) | OFF |
81
+| `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
83 82
 | `--pause` | Пауза между изображениями (сек) | 5 |
84 83
 | `--no-result-one-document` | Не объединять `.md` в один документ | — |
85 84
 | `--no-post-crop` | Не обрезать по контенту (Stage 1) | — |
86 85
 
87 86
 Пример:
88 87
 ```bash
89
-# Одна страница
90
-python -m src.image_ocr -i page.jpg
88
+# Одна страница — PaddleOCR
89
+python -m src.image_ocr -i page.jpg --ocr-engine paddle
91 90
 
92
-# Пакетный режим — все изображения в каталоге
91
+# Одна страница — Surya 2
92
+python -m src.image_ocr -i page.jpg --ocr-engine surya
93
+
94
+# Пакетный режим
93 95
 python -m src.image_ocr -i ./pages/ --ocr-engine surya
94 96
 
97
+# Внешняя VLM как OCR-движок (Qwen3.8 Max)
98
+python -m src.image_ocr -i page.jpg --ocr-engine external-qwen3
99
+
95 100
 # Без объединения в один документ
96
-python -m src.image_ocr -i ./pages/ --no-result-one-document
101
+python -m src.image_ocr -i ./pages/ --ocr-engine surya --no-result-one-document
97 102
 
98 103
 # С LLM-корректором
99
-python -m src.image_ocr -i page.jpg -l ru --llm
100
-
101
-# С VLM-корректором (нужен OPENCODE_API_KEY в .env)
102
-python -m src.image_ocr -i page.jpg --vlm
104
+python -m src.image_ocr -i page.jpg --ocr-engine paddle --fix-by-llm
103 105
 ```
104 106
 
105 107
 ### Debug-лог

+ 17
- 24
src/image_ocr.py View File

@@ -48,15 +48,16 @@ logger = logging.getLogger(__name__)
48 48
 app = typer.Typer(add_completion=False, no_args_is_help=True)
49 49
 
50 50
 MD_EXTENSION: str = ".md"
51
-DEFAULT_LANG: str = "en"
52 51
 IMG_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".tiff", ".tif"}
53 52
 
54 53
 
55 54
 def _get_ocr_engine(name: str):
56 55
     if name == "surya":
57 56
         from src.ocr.surya_engine import surya_ocr_image
58
-
59 57
         return surya_ocr_image, "Surya 2 VLM"
58
+    if name == "external-qwen3":
59
+        from src.ocr.external_vlm_engine import external_vlm_ocr_image
60
+        return external_vlm_ocr_image, "Qwen3.8 Max (external VLM)"
60 61
     return ocr_image, "PP-StructureV3"
61 62
 
62 63
 
@@ -73,7 +74,7 @@ def _merge_markdown_files(output_dir: Path, md_files: list[Path]) -> None:
73 74
 
74 75
 
75 76
 def _process_single(
76
-    ocr_fn, input_path: Path, lang: str,
77
+    ocr_fn, input_path: Path,
77 78
     output_dir_resolved: Path, use_llm: bool, use_vlm: bool,
78 79
 ) -> None:
79 80
     """Обрабатывает одно изображение: OCR → fixups → корректоры → JSON + Markdown."""
@@ -136,6 +137,13 @@ def image_to_latex(
136 137
             help="Путь к входному изображению или каталогу (JPEG, PNG, TIFF)",
137 138
         ),
138 139
     ],
140
+    ocr_engine: Annotated[
141
+        str,
142
+        typer.Option(
143
+            "--ocr-engine",
144
+            help="OCR-движок: paddle (PP-StructureV3), surya (Surya 2 VLM) или external-qwen3 (Qwen3.8 Max API)",
145
+        ),
146
+    ],
139 147
     output_dir: Annotated[
140 148
         Path | None,
141 149
         typer.Option(
@@ -147,33 +155,18 @@ def image_to_latex(
147 155
             help="Каталог для сохранения результатов (по умолчанию — рядом с входным файлом)",
148 156
         ),
149 157
     ] = None,
150
-    lang: Annotated[
151
-        str,
152
-        typer.Option(
153
-            "--lang",
154
-            "-l",
155
-            help="Язык OCR (en, ru, ch, ...)",
156
-        ),
157
-    ] = DEFAULT_LANG,
158
-    ocr_engine: Annotated[
159
-        str,
160
-        typer.Option(
161
-            "--ocr-engine",
162
-            help="OCR-движок: paddle (PP-StructureV3) или surya (Surya 2 VLM)",
163
-        ),
164
-    ] = "paddle",
165 158
     use_llm: Annotated[
166 159
         bool,
167 160
         typer.Option(
168
-            "--llm",
169
-            help="Использовать локальный LLM-корректор (Qwen2.5-7B GGUF) для исправления OCR-ошибок",
161
+            "--fix-by-llm",
162
+            help="Исправить OCR-ошибки локальным LLM (Qwen2.5-7B). Не работает с --ocr-engine external-qwen3",
170 163
         ),
171 164
     ] = False,
172 165
     use_vlm: Annotated[
173 166
         bool,
174 167
         typer.Option(
175
-            "--vlm",
176
-            help="Использовать vision LM (Qwen3.8 Max) для распознавания сомнительных регионов. Токен: OPENCODE_API_KEY",
168
+            "--fix-by-external-qwen3",
169
+            help="Исправить OCR-ошибки внешней VLM (Qwen3.8 Max, API). Не работает с --ocr-engine external-qwen3",
177 170
         ),
178 171
     ] = False,
179 172
     result_one_document: Annotated[
@@ -230,7 +223,7 @@ def image_to_latex(
230 223
 
231 224
         for i, img_path in enumerate(image_paths):
232 225
             logger.info("[%d/%d] %s...", i + 1 + skipped, len(image_paths) + skipped, img_path.name)
233
-            _process_single(ocr_fn, img_path, lang, out, use_llm, use_vlm)
226
+            _process_single(ocr_fn, img_path, out, use_llm, use_vlm)
234 227
             if i < len(image_paths) - 1 and pause > 0:
235 228
                 time.sleep(pause)
236 229
 
@@ -248,7 +241,7 @@ def image_to_latex(
248 241
     else:
249 242
         out = output_dir.resolve() if output_dir else input.resolve().parent
250 243
         out.mkdir(parents=True, exist_ok=True)
251
-        _process_single(ocr_fn, input, lang, out, use_llm, use_vlm)
244
+        _process_single(ocr_fn, input, out, use_llm, use_vlm)
252 245
 
253 246
 
254 247
 def main() -> None:

+ 70
- 0
src/ocr/external_vlm_engine.py View File

@@ -0,0 +1,70 @@
1
+from __future__ import annotations
2
+
3
+import logging
4
+import time
5
+
6
+import cv2
7
+
8
+from src.ocr.paddle_engine import OcrPageResult, ParsedBlock
9
+from src.postprocess.qwen_client import call_qwen_vlm, image_to_base64
10
+
11
+logger = logging.getLogger(__name__)
12
+
13
+_IMAGE_MAX_WIDTH: int = 1024
14
+
15
+
16
+def _pre_scale(image):
17
+    h, w = image.shape[:2]
18
+    if w > _IMAGE_MAX_WIDTH:
19
+        ratio = _IMAGE_MAX_WIDTH / w
20
+        return cv2.resize(image, (_IMAGE_MAX_WIDTH, int(h * ratio)))
21
+    return image
22
+
23
+
24
+def _parse_response_to_blocks(text: str, image) -> OcrPageResult:
25
+    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
26
+    blocks: list[ParsedBlock] = []
27
+    for para in paragraphs:
28
+        label = "text"
29
+        if para.startswith("#"):
30
+            label = "SectionHeader"
31
+        elif para.startswith(("$$", "\\[")):
32
+            label = "formula"
33
+        blocks.append(ParsedBlock(label=label, content=para, bbox=(0, 0, image.shape[1], image.shape[0])))
34
+    return OcrPageResult(
35
+        blocks=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
+    )
40
+
41
+
42
+def external_vlm_ocr_image(image_path: str) -> OcrPageResult:
43
+    image = cv2.imread(image_path)
44
+    if image is None:
45
+        raise FileNotFoundError(f"Не удалось загрузить: {image_path}")
46
+
47
+    scaled = _pre_scale(image)
48
+    logger.debug("VLM: отправка %dx%d px → %dx%d px", image.shape[1], image.shape[0],
49
+                 scaled.shape[1], scaled.shape[0])
50
+
51
+    t0 = time.time()
52
+    b64 = image_to_base64(scaled)
53
+    text = call_qwen_vlm(
54
+        b64,
55
+        (
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."
61
+        ),
62
+        max_tokens=4000,
63
+    )
64
+    dt = time.time() - t0
65
+    logger.debug("VLM: ответ за %.1fs", dt)
66
+
67
+    if not text:
68
+        raise RuntimeError("VLM вернул пустой ответ")
69
+
70
+    return _parse_response_to_blocks(text, image)

+ 83
- 0
src/postprocess/qwen_client.py View File

@@ -0,0 +1,83 @@
1
+from __future__ import annotations
2
+
3
+import base64
4
+import logging
5
+import os
6
+import time as _time
7
+
8
+import cv2
9
+import requests
10
+
11
+from src.env import load_env
12
+
13
+logger = logging.getLogger(__name__)
14
+
15
+API_URL: str = "https://opencode.ai/zen/go/v1/messages"
16
+DEFAULT_MODEL: str = "qwen3.8-max"
17
+IMAGE_QUALITY: int = 90
18
+
19
+_ENV_LOADED: bool = False
20
+
21
+
22
+def _ensure_env() -> None:
23
+    global _ENV_LOADED
24
+    if _ENV_LOADED:
25
+        return
26
+    load_env()
27
+    _ENV_LOADED = True
28
+
29
+
30
+def _api_key() -> str:
31
+    _ensure_env()
32
+    return os.environ.get("OPENCODE_API_KEY", "")
33
+
34
+
35
+def image_to_base64(image) -> str:
36
+    _, buf = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, IMAGE_QUALITY])
37
+    return base64.b64encode(buf).decode("utf-8")
38
+
39
+
40
+def call_qwen_vlm(
41
+    image_b64: str,
42
+    prompt: str,
43
+    model: str = DEFAULT_MODEL,
44
+    max_tokens: int = 300,
45
+) -> str | None:
46
+    """Отправляет изображение + prompt в Qwen3.8 Max API и возвращает текст ответа."""
47
+    if not _api_key():
48
+        logger.warning("OPENCODE_API_KEY не задан")
49
+        return None
50
+
51
+    messages = [{
52
+        "role": "user",
53
+        "content": [
54
+            {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}},
55
+            {"type": "text", "text": prompt},
56
+        ],
57
+    }]
58
+
59
+    for attempt in (1, 2):
60
+        try:
61
+            r = requests.post(
62
+                API_URL,
63
+                headers={"x-api-key": _api_key(), "anthropic-version": "2023-06-01", "Content-Type": "application/json"},
64
+                json={"model": model, "max_tokens": max_tokens, "messages": messages},
65
+                timeout=120,
66
+            )
67
+            r.raise_for_status()
68
+            data = r.json()
69
+            for item in data.get("content", []):
70
+                if item.get("type") == "text":
71
+                    return item.get("text", "").strip()
72
+            return None
73
+        except requests.HTTPError as e:
74
+            if e.response is not None and e.response.status_code == 500 and attempt == 1:
75
+                logger.info("Qwen API: HTTP 500, ожидание 5с и повтор...")
76
+                _time.sleep(5)
77
+                continue
78
+            logger.warning("Qwen API: ошибка — %s", e)
79
+            return None
80
+        except (requests.RequestException, KeyError, IndexError) as e:
81
+            logger.warning("Qwen API: ошибка — %s", e)
82
+            return None
83
+    return None

+ 17
- 103
src/postprocess/vlm_corrector.py View File

@@ -1,50 +1,18 @@
1 1
 from __future__ import annotations
2 2
 
3
-import base64
4 3
 import logging
5
-import os
6
-import time as _time
7
-from typing import Any
8 4
 
9 5
 import cv2
10
-import requests
11 6
 
12
-from src.env import load_env
13 7
 from src.ocr.paddle_engine import ParsedBlock
8
+from src.postprocess.qwen_client import call_qwen_vlm, image_to_base64
14 9
 
15 10
 logger = logging.getLogger(__name__)
16 11
 
17
-API_KEY_ENV: str = "OPENCODE_API_KEY"
18
-API_URL_ENV: str = "OPENCODE_API_URL"
19
-DEFAULT_MODEL: str = "qwen3.8-max"
20 12
 
21
-
22
-def _get_api_key() -> str:
23
-    load_env()
24
-    return os.environ.get(API_KEY_ENV, "")
25
-
26
-
27
-def _get_api_url() -> str:
28
-    return os.environ.get(API_URL_ENV, "https://opencode.ai/zen/go/v1/messages")
29
-
30
-SYSTEM_PROMPT: str = (
31
-    "Extract ALL visible text from this document image region. "
32
-    "Preserve LaTeX math ($...$ and $$...$$). "
33
-    "Return ONLY the corrected text, no commentary."
34
-)
35
-
36
-
37
-def _image_to_base64(image: Any) -> str:
38
-    _, buffer = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 90])
39
-    return base64.b64encode(buffer).decode("utf-8")
40
-
41
-
42
-def vlm_correct_block(block: ParsedBlock, image_path: str, model: str = DEFAULT_MODEL) -> None:
13
+def vlm_correct_block(block: ParsedBlock, image_path: str) -> None:
43 14
     if block.label == "formula":
44 15
         return
45
-    if not _get_api_key():
46
-        logger.warning("VLM: %s не задан, пропускаем", API_KEY_ENV)
47
-        return
48 16
 
49 17
     image = cv2.imread(image_path)
50 18
     if image is None:
@@ -52,76 +20,22 @@ def vlm_correct_block(block: ParsedBlock, image_path: str, model: str = DEFAULT_
52 20
 
53 21
     h, w = image.shape[:2]
54 22
     x1, y1, x2, y2 = block.bbox
55
-    x1 = max(0, x1 - 5)
56
-    y1 = max(0, y1 - 5)
57
-    x2 = min(w, x2 + 5)
58
-    y2 = min(h, y2 + 5)
23
+    x1, y1 = max(0, x1 - 5), max(0, y1 - 5)
24
+    x2, y2 = min(w, x2 + 5), min(h, y2 + 5)
59 25
     if x1 >= x2 or y1 >= y2:
60 26
         return
61 27
 
62 28
     crop = image[y1:y2, x1:x2]
63
-    crop_h, crop_w = crop.shape[:2]
64
-    logger.info("VLM: отправка региона %dx%d px (bbox %d,%d,%d,%d)", crop_w, crop_h, x1, y1, x2, y2)
65
-    b64 = _image_to_base64(crop)
66
-
67
-    messages = [
68
-        {
69
-            "role": "user",
70
-            "content": [
71
-                {
72
-                    "type": "image",
73
-                    "source": {
74
-                        "type": "base64",
75
-                        "media_type": "image/jpeg",
76
-                        "data": b64,
77
-                    },
78
-                },
79
-                {
80
-                    "type": "text",
81
-                    "text": f"{SYSTEM_PROMPT}\n\nExtract all text from this image region. Return ONLY the extracted text.",
82
-                },
83
-            ],
84
-        },
85
-    ]
86
-
87
-    for attempt in (1, 2):
88
-        try:
89
-            response = requests.post(
90
-                _get_api_url(),
91
-                headers={
92
-                    "x-api-key": _get_api_key(),
93
-                    "anthropic-version": "2023-06-01",
94
-                    "Content-Type": "application/json",
95
-                },
96
-                json={
97
-                    "model": model,
98
-                    "max_tokens": 300,
99
-                    "messages": messages,
100
-                },
101
-                timeout=120,
102
-            )
103
-            response.raise_for_status()
104
-            data = response.json()
105
-            text = ""
106
-            for item in data.get("content", []):
107
-                if item.get("type") == "text":
108
-                    text = item.get("text", "").strip()
109
-                    break
110
-            if text and len(text) > 5:
111
-                block.content = text
112
-                logger.info("VLM: результат (%d символов): %s", len(text), text[:120])
113
-            else:
114
-                logger.info("VLM: пустой или короткий ответ (%d символов)", len(text) if text else 0)
115
-            return
116
-        except requests.HTTPError as e:
117
-            if e.response is not None and e.response.status_code == 500 and attempt == 1:
118
-                logger.info("VLM: HTTP 500, ожидание 5с и повтор...")
119
-                _time.sleep(5)
120
-                continue
121
-            logger.warning("VLM: ошибка — %s", e)
122
-            return
123
-        except (requests.RequestException, KeyError, IndexError) as e:
124
-            logger.warning("VLM: ошибка — %s", e)
125
-            return
126
-
127
-    return
29
+    logger.info("VLM: отправка региона %dx%d px (bbox %d,%d,%d,%d)", x2 - x1, y2 - y1, x1, y1, x2, y2)
30
+
31
+    b64 = image_to_base64(crop)
32
+    text = call_qwen_vlm(
33
+        b64,
34
+        "Extract ALL visible text from this image region. Preserve LaTeX math. Return ONLY the extracted text.",
35
+        max_tokens=300,
36
+    )
37
+    if text and len(text) > 5:
38
+        block.content = text
39
+        logger.info("VLM: результат (%d символов): %s", len(text), text[:120])
40
+    elif text is not None:
41
+        logger.info("VLM: пустой или короткий ответ (%d символов)", len(text) if text else 0)

Loading…
Cancel
Save