Browse Source

updated postprocessing for tables conjunction

master
Evgeniy Ierusalimov 2 weeks ago
parent
commit
e00fedea0f
4 changed files with 62 additions and 9 deletions
  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 View File

141
 | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
141
 | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
142
 | `--pause` | Пауза между изображениями (сек) | 5 |
142
 | `--pause` | Пауза между изображениями (сек) | 5 |
143
 
143
 
144
-Выход: `.raw`-файл с сырым ответом движка (write-once, не изменяется при постпроцессинге).
144
+Выход: `.raw`-файл с сырым ответом движка — write-once с режимом `0444` (read-only), не изменяется при постпроцессинге.
145
 
145
 
146
 Пример:
146
 Пример:
147
 ```bash
147
 ```bash
168
 | `--skip-latex` | Не генерировать LaTeX и HTML | OFF |
168
 | `--skip-latex` | Не генерировать LaTeX и HTML | OFF |
169
 
169
 
170
 Что делает:
170
 Что делает:
171
-1. **Фильтрация номеров страниц** — блоки вида `42`, `[73]` в конце страницы удаляются
172
-2. **Восстановление таблиц** — таблицы, разорванные между страницами, объединяются (по кол-ву колонок)
171
+1. **Фильтрация номеров страниц** — блоки вида `42`, `[73]` в **начале и конце** страницы удаляются
172
+2. **Восстановление таблиц** — таблицы, разорванные между страницами (в т.ч. с заголовком «Продолжение таблицы»), объединяются по кол-ву колонок
173
 3. **Очистка формул** — `\text{score\_beh}` → `score_beh` для читаемости LLM
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
 ```bash
181
 ```bash
177
 python -m src.generate_result -i cbr2/
182
 python -m src.generate_result -i cbr2/

+ 38
- 5
src/generate_result.py View File

26
 
26
 
27
 _TABLE_ROW: re.Pattern = re.compile(r"^\|.+\|$")
27
 _TABLE_ROW: re.Pattern = re.compile(r"^\|.+\|$")
28
 _UNESCAPE_TEXT: re.Pattern = re.compile(r"\\text\{([^}]+)\}")
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
 def _clean_formula(text: str) -> str:
32
 def _clean_formula(text: str) -> str:
79
     return None, -1
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
 def _merge_split_tables(pages: list[dict], names: list[str]) -> int:
99
 def _merge_split_tables(pages: list[dict], names: list[str]) -> int:
83
     """Объединяет split tables, охватывающие несколько соседних страниц (in-place)."""
100
     """Объединяет split tables, охватывающие несколько соседних страниц (in-place)."""
84
     merged_count = 0
101
     merged_count = 0
94
         j = i + 1
111
         j = i + 1
95
         while j < len(pages):
112
         while j < len(pages):
96
             blocks_b = pages[j].get("blocks", pages[j].get("parsing_res_list", []))
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
             if first_b is None or _table_ncols(first_b) != ncols_a:
115
             if first_b is None or _table_ncols(first_b) != ncols_a:
99
                 break
116
                 break
100
 
117
 
114
                     break
131
                     break
115
 
132
 
116
             last_a["content"] = (last_a.get("content", "") or "").rstrip() + "\n" + tail_rows
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
             merged_count += 1
141
             merged_count += 1
119
             logger.info("Таблицы объединены: %s + %s (%d колонок)", names[i], names[j], ncols_a)
142
             logger.info("Таблицы объединены: %s + %s (%d колонок)", names[i], names[j], ncols_a)
120
             j += 1
143
             j += 1
124
     return merged_count
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
 def _filter_page_numbers(blocks: list[dict]) -> list[dict]:
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
     if dropped:
161
     if dropped:
130
         nums = ", ".join((b["content"] or "").strip() for b in dropped)
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
 def _process_raw(raw_path: Path) -> dict | None:
167
 def _process_raw(raw_path: Path) -> dict | None:
155
         return content
187
         return content
156
     from bs4 import BeautifulSoup
188
     from bs4 import BeautifulSoup
157
 
189
 
190
+    content = re.sub(r"<br\s*/?>", " ", content, flags=re.IGNORECASE)
158
     soup = BeautifulSoup(content, "html.parser")
191
     soup = BeautifulSoup(content, "html.parser")
159
     for math_tag in soup.find_all("math"):
192
     for math_tag in soup.find_all("math"):
160
         display = math_tag.get("display", "") == "block"
193
         display = math_tag.get("display", "") == "block"

+ 1
- 0
src/image_ocr.py View File

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

+ 14
- 0
stage2_results.md View File

305
 
305
 
306
 Ключевой принцип: **`.raw` — неизменяемый**. Все операции постпроцессинга читают `.raw` и пишут результат в отдельные файлы. Повторный запуск Stage 3 даёт идентичный результат (идемпотентность).
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
 ### VLM-промпт (Qwen3.8 Max) — ключевое улучшение ✅
310
 ### VLM-промпт (Qwen3.8 Max) — ключевое улучшение ✅
309
 
311
 
310
 Старый промпт (4 строки) → новый (детальный, 30 строк):
312
 Старый промпт (4 строки) → новый (детальный, 30 строк):
325
 
327
 
326
 Таблицы, разорванные между страницами (заголовок на стр. N, строки на стр. N+1...), объединяются по кол-ву колонок. Цепочки из 2-4 страниц склеиваются в одну таблицу (например, Таблица 76 на 4 страницах).
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
 ### Unwarp-баг и фикс ⚠️→✅
342
 ### Unwarp-баг и фикс ⚠️→✅
329
 
343
 
330
 Первая версия unwarp искала 4-угольный контур и warp'ила в него — на плоских сканах находила внутренние таблицы и обрезала до 40% высоты. Фикс: порог `min_area ≥ 30%` + проверка, что результат ≥ 80% оригинала. Для плоского сканера unwarp = NOP.
344
 Первая версия unwarp искала 4-угольный контур и warp'ила в него — на плоских сканах находила внутренние таблицы и обрезала до 40% высоты. Фикс: порог `min_area ≥ 30%` + проверка, что результат ≥ 80% оригинала. Для плоского сканера unwarp = NOP.

Loading…
Cancel
Save