# 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: подготовка (разрезание, unwarp, denoise) │ --slice / --slice-auto │ --pre-rotate, --border │ --unwarp, --denoise, --post-crop │ ▼ JPEG/PNG страницы │ ▼ src/image_ocr.py ← Stage 2: OCR → сырой ответ │ --ocr-engine paddle|surya|external-qwen3 │ --main-lang ru|en │ --fix-by-external-qwen3 │ ▼ .raw (сырой ответ VLM, неизменяемый) │ ▼ src/generate_result.py ← Stage 3: постпроцессинг │ фильтрация номеров страниц │ восстановление таблиц между страницами │ очистка формул (\text, \_) │ ▼ .json + .md + объединённый .md + .tex + .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) | 10 | | `--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] Отступ в пикселях │ │ (белая рамка / │ │ отступ при обрезке │ │ содержимого, по │ │ умолчанию 10) │ │ [default: 10] │ │ --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` | — | | `--main-lang` | Основной язык: `ru` (eslav rec) или `en` (PP-OCRv6 rec) | `ru` | | `--fix-by-external-qwen3` | Исправить ошибки VLM API (Qwen3.8 Max) | OFF | | `--pause` | Пауза между изображениями (сек) | 5 | Выход: `.raw`-файл с сырым ответом движка — write-once с режимом `0444` (read-only), не изменяется при постпроцессинге. Пример: ```bash # Одна страница — PaddleOCR python -m src.image_ocr -i page.jpg --ocr-engine paddle # Внешняя VLM как OCR-движок (Qwen3.8 Max) — лучший для русского python -m src.image_ocr -i pages/ --ocr-engine external-qwen3 # Английский документ (PP-OCRv6 rec) python -m src.image_ocr -i page.jpg --ocr-engine paddle --main-lang en # Пакетный режим — Surya 2 (batch) python -m src.image_ocr -i ./pages/ --ocr-engine surya ``` ### Stage 3: `src/generate_result.py` Постпроцессинг `.raw` → `.json` + `.md` + `.tex` + `.html`. | Параметр | Описание | По умолчанию | |----------|----------|:---:| | `--input` / `-i` | Каталог с `.raw`-файлами | required | | `--skip-latex` | Не генерировать LaTeX и HTML | OFF | Что делает: 1. **Фильтрация номеров страниц** — блоки вида `42`, `[73]` в **начале и конце** страницы удаляются 2. **Восстановление таблиц** — таблицы, разорванные между страницами (в т.ч. с заголовком «Продолжение таблицы»), объединяются по кол-ву колонок 3. **Очистка формул** — `\text{score\_beh}` → `score_beh` для читаемости LLM 4. **Нормализация HTML** — `
` в ячейках таблиц → пробел (не разрывает pipe-структуру) 5. **Генерация** — объединённый `.md` + `.tex` + `.html` (MathJax) ### Иммутабельность `.raw` Stage 2 (`image_ocr.py`) пишет `.raw` **один раз** и ставит файлу режим `0444` (read-only). Stage 3 (`generate_result.py`) только читает `.raw` и пишет результаты в отдельные файлы (`.json`, `.md`, `.tex`, `.html`). Повторный запуск Stage 3 идемпотентен — даёт идентичный результат. ```bash python -m src.generate_result -i cbr2/ ```
--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 │ │ --main-lang Основной язык: ru │ │ (eslav_PP-OCRv5_mobi… │ │ или en │ │ (PP-OCRv6_medium_rec) │ │ [default: ru] │ │ --pause Пауза между │ │ [0<=x<=600] изображениями в │ │ секундах (по │ │ умолчанию 5) │ │ [default: 5] │ │ --help Show this message and │ │ exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ```
### Stage 3: `src/generate_result.py` — `--help`
--help ``` Usage: python -m src.generate_result [OPTIONS] Постобработка результатов OCR: фильтрация, таблицы, объединение, LaTeX+HTML. ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ * --input -i Путь к каталогу с .raw-файлами │ │ (результаты OCR) │ │ [required] │ │ --skip-latex Не генерировать LaTeX и HTML │ │ --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` — не коммитится. --- ## Результат ### Stage 2 (`image_ocr.py`) — сырой ответ | Файл | Формат | Описание | |------|--------|----------| | `page_01.raw` | JSON | Сырой ответ OCR-движка. **Неизменяемый** — пишется один раз, все постпроцессинги читают его | ### Stage 3 (`generate_result.py`) — итоговые файлы | Файл | Формат | Описание | |------|--------|----------| | `page_01.json` | JSON | Очищенный результат (фильтр номеров страниц, объединённые таблицы) | | `page_01.md` | Markdown | Текст + `$$`-формулы + `##`-заголовки. **Готов к загрузке в LLM.** | | `.md` | Markdown | Объединённый многостраничный документ | | `.tex` | LaTeX | LaTeX для всех страниц | | `.html` | HTML | HTML + MathJax для просмотра в браузере | ```bash # Полный цикл python -m src.image_prepare -i scan.jpg -s 3:3 -o pages/ python -m src.image_ocr -i pages/ --ocr-engine external-qwen3 python -m src.generate_result -i pages/ ``` --- ## Качество OCR | Движок | Русский текст | Английский | Формулы | Скорость | |--------|:---:|:---:|:---:|:---:| | PaddleOCR `--main-lang ru` (eslav rec + v6 det) | ~85-90% | ~60% | ✅ PP-FormulaNet | ~100-160s CPU | | PaddleOCR `--main-lang en` (v6 rec) | — | ~75-80% | ✅ PP-FormulaNet | ~100-160s CPU | | Surya 2 | ~95% | ~95% | ✅ VLM | ~300s CPU | | External VLM (Qwen3.8 Max) | ~98% | ~98% | ✅✅ | ~60-120s API | **Детектор:** PP-OCRv6_medium_det (RepLKFPN) — +4.6% vs v5_server. --- ## Передача в 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/ models.py — ParsedBlock, OcrPageResult image_utils.py — calc_scale_dims env.py — загрузка .env cli_utils.py — run_main() image_prepare.py — CLI Stage 1 (разрезание, unwarp, denoise) image_ocr.py — CLI Stage 2 (OCR → .raw) generate_result.py — CLI Stage 3 (постпроцессинг → .json/.md/.tex/.html) split/slicer.py — разрезание, post-crop, border, unwarp, denoise ocr/ paddle_engine.py — PP-StructureV3 + v6-det + ru/en rec surya_engine.py — Surya 2 + batch + CPU-оптимизации external_vlm_engine.py — Qwen3.8 Max full-page OCR postprocess/ fixups.py — OCR-ошибки (VaR, V^2) corrector.py — apply_corrector (Callable) vlm_corrector.py — Qwen3.8 Max per-block qwen_client.py — общий Qwen API-клиент markdown/ tables.py — общие функции таблиц (parse, detect, ncols) generator.py — Markdown + валидация LaTeX 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.