# scan2html — Offline Document OCR Pipeline
Полностью офлайн-конвейер обработки отсканированных документов:
разрезание изображений на страницы → OCR → структурированный Markdown.
---
## Цель проекта
Преобразование больших технических документов (JPEG/TIFF/PNG), полученных после печати и сканирования, в структурированный Markdown с формулами LaTeX, пригодный для:
- просмотра в браузере (HTML + MathJax)
- анализа LLM
- конвертации в LaTeX/PDF
**Полностью offline.** Без облачных сервисов.
---
## Pipeline
```
JPEG/TIFF/PNG (многостраничный скан)
│
▼
src/image_prepare.py ← Stage 1: подготовка (разрезание, обрезка)
│ --slice / --slice-auto
│ --pre-rotate, --border
│ --post-crop
│
▼
JPEG страницы
│
▼
src/image_ocr.py ← Stage 2: OCR + Markdown
│ --ocr-engine paddle|surya
│ --input file.jpg (одна страница)
│ --input pages/ (пакетный режим: все изображения в каталоге)
│ --llm, --vlm, --force-ocr
│ --pause 5 (по умолчанию)
│ --result-one-document (по умолчанию ON, объединить в один .md)
├─ PP-StructureV3 / Surya 2 (OCR)
├─ fixups (VaR, V^2)
├─ VLM corrector (Qwen3.8 Max, API)
│
▼
.md + .json + .html
```
---
## Параметры
### Stage 1: `src/image_prepare.py`
| Параметр | Описание | По умолчанию |
|----------|----------|:---:|
| `--input` / `-i` | Путь к входному изображению | required |
| `--slice` / `-s` | Сетка `:` | — |
| `--slice-auto` / `-a` | Автоопределение сетки | — |
| `--output-dir` / `-o` | Каталог для страниц | `.` |
| `--pre-rotate` / `-r` | Поворот: 90, 180, 270 | — |
| `--border` / `-b` | Белая рамка (px) | 5 |
| `--unwarp` / `-u` | Выпрямить перспективные искажения (Canny → 4-угольный контур → warp) | OFF |
| `--denoise` | Убрать шум сканера (Non-Local Means) | OFF |
| `--post-crop` / `--no-post-crop` | Обрезка по контенту | ON |
Пример:
```bash
python -m src.image_prepare -i scan.jpg -s 3:3 -b 10 -o pages/
python -m src.image_prepare -i scan.jpg --slice-auto
python -m src.image_prepare -i photo.jpg --unwarp -s 2:2 -b 10
python -m src.image_prepare -i old_scan.jpg --denoise -s 2:2 -b 10
```
--help
```
Usage: python -m src.image_prepare [OPTIONS]
Подготовить отсканированное изображение: разрезать по сетке, обрезать,
добавить рамку.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --input -i Путь к входному │
│ изображению (JPEG, │
│ PNG, TIFF) │
│ --slice -s Размер сетки в │
│ формате │
│ <колонки>:<строки>, │
│ например 3:2 │
│ --slice-auto -a Автоматически │
│ определить размер │
│ сетки по содержимому │
│ изображения │
│ --output-dir -o Каталог для │
│ сохранения │
│ результатов (по │
│ умолчанию — текущий) │
│ [default: .] │
│ --pre-rotate -r Поворот изображения │
│ перед разрезанием: │
│ 90, 180 или 270 │
│ --border -b [x>=0] Отступ в пикселях │
│ (белая рамка / │
│ отступ при обрезке │
│ содержимого, по │
│ умолчанию 5) │
│ [default: 5] │
│ --unwarp -u Выпрямить │
│ перспективные │
│ искажения перед │
│ разрезанием │
│ --denoise Убрать шум сканера │
│ (Non-Local Means) │
│ перед разрезанием │
│ --post-crop -c --no-post-crop Обрезать каждую │
│ страницу по границам │
│ полезного │
│ содержимого │
│ (включено по │
│ умолчанию) │
│ [default: post-crop] │
│ --help Show this message │
│ and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
### Stage 2: `src/image_ocr.py`
| Параметр | Описание | По умолчанию |
|----------|----------|:---:|
| `--input` / `-i` | Файл изображения **или** каталог | required |
| `--output-dir` / `-o` | Каталог вывода | рядом с `--input` |
| `--ocr-engine` | **Обязательный:** `paddle`, `surya` или `external-qwen3` | — |
| `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF |
| `--main-lang` | Основной язык: `ru` (eslav) или `en` (PP-OCRv6 rec) | `ru` |
| `--pause` | Пауза между изображениями (сек) | 5 |
| `--no-result-one-document` | Не объединять `.md` в один документ | — |
| `--no-post-crop` | Не обрезать по контенту (Stage 1) | — |
Пример:
```bash
# Одна страница — PaddleOCR
python -m src.image_ocr -i page.jpg --ocr-engine paddle
# Одна страница — Surya 2
python -m src.image_ocr -i page.jpg --ocr-engine surya
# Пакетный режим
python -m src.image_ocr -i ./pages/ --ocr-engine surya
# Внешняя VLM как OCR-движок (Qwen3.8 Max)
python -m src.image_ocr -i page.jpg --ocr-engine external-qwen3
# Без объединения в один документ
python -m src.image_ocr -i ./pages/ --ocr-engine surya --no-result-one-document
# С VLM-корректором
python -m src.image_ocr -i page.jpg --ocr-engine paddle --fix-by-external-qwen3
# Английский документ (PP-OCRv6 rec)
python -m src.image_ocr -i page.jpg --ocr-engine paddle --main-lang en
```
--help
```
Usage: python -m src.image_ocr [OPTIONS]
OCR → PostProcess → Markdown. Принимает файл или каталог изображений.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ * --input -i Путь к входному │
│ изображению или │
│ каталогу (JPEG, PNG, │
│ TIFF) │
│ [required] │
│ * --ocr-engine OCR-движок: paddle │
│ (PP-StructureV3), │
│ surya (Surya 2 VLM) │
│ или external-qwen3 │
│ (Qwen3.8 Max API) │
│ [required] │
│ --output-dir -o Каталог для │
│ сохранения │
│ результатов (по │
│ умолчанию — рядом с │
│ входным файлом) │
│ --fix-by-external-q… Исправить OCR-ошибки │
│ внешней VLM (Qwen3.8 │
│ Max, API). Не │
│ работает с │
│ --ocr-engine │
│ external-qwen3 │
│ --no-result-one-doc… Не объединять .md │
│ результаты в один │
│ документ │
│ [default: True] │
│ --pause Пауза между │
│ [0<=x<=600] изображениями в │
│ секундах (по │
│ умолчанию 5) │
│ [default: 5] │
│ --help Show this message and │
│ exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
### Debug-лог
Для отладки зависаний и падений — файл с полным логом всех этапов обработки.
В `.env` добавить:
```
OCR_LOG_FILE=image_ocr.log
```
Формат вывода: `ЧЧ:ММ:СС [LEVEL] сообщение`. Файл пишется рядом со скриптом или по указанному пути. Уровень DEBUG показывает: начало/конец OCR, сохранение JSON/MD, вызов корректоров.
### Переменные окружения (`.env`)
Скопируй шаблон и заполни своими ключами:
```bash
cp .env.dist .env
# Отредактируй .env — вставь свои API-ключи
```
| Переменная | Назначение | Где взять |
|-----------|-----------|-----------|
| `OPENCODE_API_KEY` | VLM-корректор через OpenCode Go | [opencode.ai/auth](https://opencode.ai/auth) → скопировать ключ |
| `HF_TOKEN` | Скачивание моделей с HuggingFace (Surya 2) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (read-only) |
| `OCR_LOG_FILE` | Путь к debug-логу (необязательно) | Например: `image_ocr.log` |
`.env` уже добавлен в `.gitignore` — не коммитится.
---
## Результат
CLI (`image_ocr.py`) генерирует два файла для каждого изображения:
| Файл | Формат | Описание |
|------|--------|----------|
| `page_01.md` | Markdown | Текст + `$$`-формулы + `##`-заголовки. **Готов к загрузке в LLM.** |
| `page_01.json` | JSON | Полный дамп OCR: bbox, confidence, labels, formulas |
**При пакетной обработке** (каталог на входе) — дополнительно:
| Файл | Описание |
|------|----------|
| `.md` | Объединённый многостраничный Markdown (по умолчанию) |
| `.tex` | LaTeX для всех страниц (через `json_to_latex.py`) |
**Ручные утилиты:**
| Файл | Утилита | Назначение |
|------|---------|------------|
| `.tex` | `src/latex/json_to_latex.py` | JSON (Paddle / Surya / Qwen) → LaTeX + HTML (автодетект) |
```bash
# JSON → LaTeX (один файл, автоопределение формата)
python -m src.latex.json_to_latex page.json
# JSON → LaTeX + HTML (каталог — объединённый многостраничный)
python -m src.latex.json_to_latex cbr_en_2/
# Программный вызов
python -c "from src.latex.json_to_latex import json_to_latex; \
open('page.tex','w').write(json_to_latex('page.json'))"
python -c "from src.latex.json_to_latex import multi_json_to_latex; \
multi_json_to_latex('out/', 'out/document.tex')"
```
---
## Качество OCR
| Движок | Русский текст | Формулы | Скорость (CPU) |
|--------|:---:|:---:|:---:|
| PaddleOCR (PP-StructV3) | ~85% | ✅ отлично | ~140s |
| + VLM (Qwen3.8 Max) | ~98% | ✅✅ | +10s/блок |
| Surya 2 | ~95% | ✅ отлично | ~300s |
---
## Передача в LLM (Gemini, Qwen, ChatGPT)
**Рекомендуемый формат: Markdown (`.md`).** Это основной формат вывода CLI.
| Формат | Для LLM | Причина |
|--------|:-----:|---------|
| `.md` | ⭐⭐⭐⭐⭐ | Нативный для всех LLM. `$$`-формулы, `##`-заголовки — структура сохраняется. **Генерируется CLI.** |
| `.json` | ⭐⭐⭐⭐ | Полный дамп OCR. Для программной обработки. **Генерируется CLI.** |
| `.html` | ⭐⭐⭐ | LLM читает HTML, но тратит токены на тэги. Генерируется утилитами. |
Пример запроса к LLM после загрузки `.md`:
```
Проанализируй раздел 7.1.10 и объясни формулу $$LGD\_extr_i = \max(...)$$
```
---
## Оптимизации Surya 2 для CPU
Surya 2 использует `llama-server` (llama.cpp) для инференса на CPU. Настройки по умолчанию рассчитаны на GPU-сервер с параллельными запросами. На одном ноутбучном CPU они избыточны и замедляют работу.
### 1. Keep-alive сервера (`SURYA_INFERENCE_KEEP_ALIVE=true`)
По умолчанию llama-server запускается и останавливается при каждом вызове OCR (старт — 23 секунды). Keep-alive держит сервер живым между запросами.
**Эффект:** -23s при повторных вызовах.
### 2. Parallel slots (`SURYA_INFERENCE_PARALLEL=1`)
Количество параллельных «слотов» — сколько запросов llama-server может обрабатывать одновременно. По умолчанию 8 — нужно для сервера с множеством клиентов. Для одной локальной страницы достаточно одного.
**Эффект:** -80% памяти KV-кэша, меньше переключений контекста.
### 3. CTX per slot (`SURYA_INFERENCE_CTX_PER_SLOT=8192`)
Размер контекстного окна в токенах на один слот. Одна страница A4 при 96 DPI генерирует ~2000-2500 токенов на выходе Surya 2. Значение 8192 даёт трёхкратный запас, покрывая самые плотные страницы. По умолчанию 12288 — рассчитано на GPU-сервер с большим VRAM.
**Как определено:** замерено на `3_1200_02.jpg` — `max_tokens` в запросе ~2400. Утроение — стандартный safety-фактор для VLM (input tokens + output tokens). 8192 = 3 × ~2400.
**Эффект:** -50% памяти KV-кэша относительно 16384 (= 12288 × 8 / 8 → 12288).
### 4. Pre-scale до 96 DPI (`_IMAGE_MAX_WIDTH=1056px`)
Surya 2 обучен на данных с 96 DPI. Ширина A4 при 96 DPI ≈ 794 px, при альбомной ориентации ≈ 1056 px. Полноразмерные сканы (2552×3417 px, ~300 DPI) дают избыточную детализацию, которую vision-энкодер Surya всё равно сжимает до своего внутреннего разрешения. Предварительное сжатие экономит время на передачу и энкодинг без влияния на качество OCR.
**Документация Surya подтверждает:** _«Try going from 192 to 96 for improved throughput»_ — снижение DPI рекомендовано разработчиками как способ ускорения без потери точности.
**Эффект:** -60% пикселей на входе, +20-30% скорости vision-энкодера.
### 5. Threads match CPU (`-t 8 --threads-batch 8`)
llama-server не указывает количество потоков — по умолчанию берёт все доступные. Явное указание `-t` и `--threads-batch` гарантирует оптимальное использование всех 8 ядер без оверхеда на hyperthreading.
**Эффект:** стабильная загрузка CPU, без деградации при гипертрединге.
### Совокупный прирост
| Метрика | До оптимизации | После | Прирост |
|---------|:---:|:---:|:------:|
| Время холодного старта | ~430s | ~300s | **-30%** |
| RAM на KV-кэш | ~8 GB | ~1 GB | **-87%** |
| Повторный запуск | +23s (перестарт сервера) | 0s (keep-alive) | **-23s** |
Настройки заданы в движке (`src/ocr/surya_engine.py`), переопределяются через переменные окружения.
---
## Архитектура
```
src/
image_prepare.py — CLI Stage 1
image_ocr.py — CLI Stage 2
split/slicer.py — разрезание, post-crop
ocr/
paddle_engine.py — PP-StructureV3
surya_engine.py — Surya 2
postprocess/
fixups.py — OCR-ошибки (VaR)
corrector.py — Corrector protocol
vlm_corrector.py — Qwen3.8 Max (API)
markdown/generator.py — Markdown + валидация
latex/
json_to_latex.py — Единый JSON→LaTeX+HTML (Paddle + Surya + Qwen)
```
---
## Не входит в проект
- GUI
- Docker
- PDF (на вход)
- LLM/RAG
- Поиск по документам
- Многопоточность
- Автоматический deskew
---
## Лицензия
MIT. Модели PaddleOCR — Apache 2.0, Surya 2 — OpenRAIL-M.