Sfoglia il codice sorgente

updated postprocessing for tables conjunction

master
Evgeniy Ierusalimov 2 settimane fa
parent
commit
e00fedea0f
4 ha cambiato i file con 62 aggiunte e 9 eliminazioni
  1. 9
    4
      README.md
  2. 38
    5
      src/generate_result.py
  3. 1
    0
      src/image_ocr.py
  4. 14
    0
      stage2_results.md

+ 9
- 4
README.md Vedi File

@@ -141,7 +141,7 @@ python -m src.image_prepare -i old_scan.jpg --denoise -s 2:2 -b 10
141 141
 | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
142 142
 | `--pause` | Пауза между изображениями (сек) | 5 |
143 143
 
144
-Выход: `.raw`-файл с сырым ответом движка (write-once, не изменяется при постпроцессинге).
144
+Выход: `.raw`-файл с сырым ответом движка — write-once с режимом `0444` (read-only), не изменяется при постпроцессинге.
145 145
 
146 146
 Пример:
147 147
 ```bash
@@ -168,10 +168,15 @@ python -m src.image_ocr -i ./pages/ --ocr-engine surya
168 168
 | `--skip-latex` | Не генерировать LaTeX и HTML | OFF |
169 169
 
170 170
 Что делает:
171
-1. **Фильтрация номеров страниц** — блоки вида `42`, `[73]` в конце страницы удаляются
172
-2. **Восстановление таблиц** — таблицы, разорванные между страницами, объединяются (по кол-ву колонок)
171
+1. **Фильтрация номеров страниц** — блоки вида `42`, `[73]` в **начале и конце** страницы удаляются
172
+2. **Восстановление таблиц** — таблицы, разорванные между страницами (в т.ч. с заголовком «Продолжение таблицы»), объединяются по кол-ву колонок
173 173
 3. **Очистка формул** — `\text{score\_beh}` → `score_beh` для читаемости LLM
174
-4. **Генерация** — объединённый `.md` + `.tex` + `.html` (MathJax)
174
+4. **Нормализация HTML** — `<br>` в ячейках таблиц → пробел (не разрывает pipe-структуру)
175
+5. **Генерация** — объединённый `.md` + `.tex` + `.html` (MathJax)
176
+
177
+### Иммутабельность `.raw`
178
+
179
+Stage 2 (`image_ocr.py`) пишет `.raw` **один раз** и ставит файлу режим `0444` (read-only). Stage 3 (`generate_result.py`) только читает `.raw` и пишет результаты в отдельные файлы (`.json`, `.md`, `.tex`, `.html`). Повторный запуск Stage 3 идемпотентен — даёт идентичный результат.
175 180
 
176 181
 ```bash
177 182
 python -m src.generate_result -i cbr2/

+ 38
- 5
src/generate_result.py Vedi File

@@ -26,6 +26,7 @@ app = typer.Typer(add_completion=False, no_args_is_help=True)
26 26
 
27 27
 _TABLE_ROW: re.Pattern = re.compile(r"^\|.+\|$")
28 28
 _UNESCAPE_TEXT: re.Pattern = re.compile(r"\\text\{([^}]+)\}")
29
+_TABLE_CONTINUATION: re.Pattern = re.compile(r"Продолжение\s+таблицы|Окончание\s+таблицы", re.IGNORECASE)
29 30
 
30 31
 
31 32
 def _clean_formula(text: str) -> str:
@@ -79,6 +80,22 @@ def _last_table_block(blocks: list[dict]) -> tuple[dict | None, int]:
79 80
     return None, -1
80 81
 
81 82
 
83
+def _is_continuation_text(block: dict) -> bool:
84
+    content = (block.get("content", "") or "").strip()
85
+    return bool(content) and not _is_table_block(block) and bool(_TABLE_CONTINUATION.search(content))
86
+
87
+
88
+def _first_table_block(blocks: list[dict]) -> tuple[dict | None, int]:
89
+    """Первая таблица, пропуская префикс «Продолжение таблицы». Возвращает (блок, индекс)."""
90
+    for i, block in enumerate(blocks):
91
+        if _is_continuation_text(block):
92
+            continue
93
+        if _is_table_block(block):
94
+            return block, i
95
+        break
96
+    return None, -1
97
+
98
+
82 99
 def _merge_split_tables(pages: list[dict], names: list[str]) -> int:
83 100
     """Объединяет split tables, охватывающие несколько соседних страниц (in-place)."""
84 101
     merged_count = 0
@@ -94,7 +111,7 @@ def _merge_split_tables(pages: list[dict], names: list[str]) -> int:
94 111
         j = i + 1
95 112
         while j < len(pages):
96 113
             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
114
+            first_b, first_b_idx = _first_table_block(blocks_b)
98 115
             if first_b is None or _table_ncols(first_b) != ncols_a:
99 116
                 break
100 117
 
@@ -114,7 +131,13 @@ def _merge_split_tables(pages: list[dict], names: list[str]) -> int:
114 131
                     break
115 132
 
116 133
             last_a["content"] = (last_a.get("content", "") or "").rstrip() + "\n" + tail_rows
117
-            del blocks_b[0]
134
+            # Удалить префикс «Продолжение таблицы» и саму таблицу
135
+            del blocks_b[first_b_idx]
136
+            for idx in range(first_b_idx - 1, -1, -1):
137
+                if _is_continuation_text(blocks_b[idx]):
138
+                    del blocks_b[idx]
139
+                else:
140
+                    break
118 141
             merged_count += 1
119 142
             logger.info("Таблицы объединены: %s + %s (%d колонок)", names[i], names[j], ncols_a)
120 143
             j += 1
@@ -124,12 +147,21 @@ def _merge_split_tables(pages: list[dict], names: list[str]) -> int:
124 147
     return merged_count
125 148
 
126 149
 
150
+def _is_page_num_block(block: dict) -> bool:
151
+    return block.get("label") == "text" and bool(PAGE_NUM.match((block.get("content", "") or "").strip()))
152
+
153
+
127 154
 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())]
155
+    dropped: list[dict] = []
156
+    result = list(blocks)
157
+    while result and _is_page_num_block(result[0]):
158
+        dropped.append(result.pop(0))
159
+    while result and _is_page_num_block(result[-1]):
160
+        dropped.append(result.pop())
129 161
     if dropped:
130 162
         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, ""
163
+        return result, nums
164
+    return result, ""
133 165
 
134 166
 
135 167
 def _process_raw(raw_path: Path) -> dict | None:
@@ -155,6 +187,7 @@ def _html_to_text(content: str) -> str:
155 187
         return content
156 188
     from bs4 import BeautifulSoup
157 189
 
190
+    content = re.sub(r"<br\s*/?>", " ", content, flags=re.IGNORECASE)
158 191
     soup = BeautifulSoup(content, "html.parser")
159 192
     for math_tag in soup.find_all("math"):
160 193
         display = math_tag.get("display", "") == "block"

+ 1
- 0
src/image_ocr.py Vedi File

@@ -85,6 +85,7 @@ def _save_result(page, input_path: Path, output_dir: Path) -> None:
85 85
                    for b in page.blocks]
86 86
     }
87 87
     raw_path.write_text(json.dumps(raw_payload, ensure_ascii=False, indent=2), encoding="utf-8")
88
+    raw_path.chmod(0o444)
88 89
     logger.info("%s → %s (%d блоков)", input_path.name, raw_path.name, len(page.blocks))
89 90
 
90 91
 

+ 14
- 0
stage2_results.md Vedi File

@@ -305,6 +305,8 @@ generate_result (Stage 3) → постпроцессинг → .json + .md + .te
305 305
 
306 306
 Ключевой принцип: **`.raw` — неизменяемый**. Все операции постпроцессинга читают `.raw` и пишут результат в отдельные файлы. Повторный запуск Stage 3 даёт идентичный результат (идемпотентность).
307 307
 
308
+**Защита от записи:** `image_ocr.py` ставит `.raw` режим `0444` (read-only) сразу после записи. `generate_result.py` физически не может модифицировать `.raw` (пишет только `.json`/`.md`/`.tex`/`.html`).
309
+
308 310
 ### VLM-промпт (Qwen3.8 Max) — ключевое улучшение ✅
309 311
 
310 312
 Старый промпт (4 строки) → новый (детальный, 30 строк):
@@ -325,6 +327,18 @@ generate_result (Stage 3) → постпроцессинг → .json + .md + .te
325 327
 
326 328
 Таблицы, разорванные между страницами (заголовок на стр. N, строки на стр. N+1...), объединяются по кол-ву колонок. Цепочки из 2-4 страниц склеиваются в одну таблицу (например, Таблица 76 на 4 страницах).
327 329
 
330
+**Частные случаи:**
331
+- **«Продолжение таблицы»** — страница начинается с текста «Продолжение таблицы N», за которым идёт таблица. Детектируется по regex, префикс удаляется, таблица склеивается.
332
+- **Многострочные ячейки `<br>`** — VLM вставляет `<br>` внутри ячеек. `_html_to_text` конвертирует `<br>` → пробел до парсинга (не `\n`, иначе ломается pipe-структура).
333
+
334
+### Номера страниц вверху и внизу ✅
335
+
336
+Номера страниц могут быть в начале (верхний колонтитул) и в конце (нижний колонтитул) страницы. Фильтр удаляет их с обоих краёв, не трогая одиночные числа в середине текста.
337
+
338
+### RGBA (прозрачный фон) ✅
339
+
340
+PNG с альфа-каналом (RGBA) композитится на белый фон в `load_image` — прозрачные пиксели не трактуются как чёрный контент при бинаризации.
341
+
328 342
 ### Unwarp-баг и фикс ⚠️→✅
329 343
 
330 344
 Первая версия unwarp искала 4-угольный контур и warp'ила в него — на плоских сканах находила внутренние таблицы и обрезала до 40% высоты. Фикс: порог `min_area ≥ 30%` + проверка, что результат ≥ 80% оригинала. Для плоского сканера unwarp = NOP.

Loading…
Annulla
Salva