Реальный проект: сканер ответов, который заменяет учителю ручную проверку 19 заданий части 1
Задача, которую хочется автоматизировать
Представьте: у учителя математики — 30 учеников, у каждого заполненный «Бланк ответов №1». В нём 19 заданий с числовыми ответами, записанными по клеткам (как в кроссворде). Каждый символ — в отдельной ячейке: цифры, запятая, знак минус, дробная черта.
Учитель берёт бланк, сравнивает строку за строкой с ключом ответов, проставляет 0 или 1 балл. 30 бланков × 19 заданий = 570 ручных проверок. На это уходит около часа.
Мы решили это автоматизировать: загружаешь скан — получаешь таблицу с ✅ и ❌ по каждому заданию.
Как выглядит бланк изнутри
Бланк ОГЭ — лист А4 на 300 DPI (2480×3508 пикселей). В нём две колонки по 17 + 2 строки. Каждая строка — одно задание. Каждый символ ответа записан в отдельную ячейку размером ~60×68 пикселей.
По углам бланка — четыре угловых маркера (реперные точки): маленькие чёрные квадраты в строго фиксированных позициях. Они нужны для выравнивания: ни один сканер не кладёт бумагу идеально ровно, всегда есть сдвиг и небольшой поворот.
[■] Фамилия: [ ][ ][ ][ ][ ][ ][ ] [■]
Имя: [ ][ ][ ][ ][ ][ ]
...
Задание 1: [ ][ ][ ][ ][ ][ ][ ]...
Задание 2: [ ][ ][ ][ ][ ][ ][ ]...
...
[■] [■]
Пайплайн: 5 шагов от картинки до результата
Шаг 1. Загрузка и предобработка
Принимаем PNG/JPG/PDF. PDF сразу разбиваем по страницам через PyMuPDF (fitz). Изображение нормализуем: выравниваем экспозицию, убираем шум, конвертируем в бинарную маску (чернила/фон).
Шаг 2. Детектор реперов → выравнивание
Ищем четыре угловых маркера. Это связные компоненты определённого размера в строго ограниченных зонах поиска. Находим их — вычисляем трансформацию перспективы, приводим изображение к эталонным координатам (2480×3508 пикселей).
repers = reper_detector.detect(binary_image)
aligned, meta = aligner.align(image, repers)
Если маркеры не найдены — бланк возвращается с ошибкой. Без выравнивания всё остальное бессмысленно.
Шаг 3. Извлечение ячеек по шаблону
В JSON-шаблоне для каждого варианта экзамена записано: где начинается сетка ответов, шаг по X и Y, размер ячейки. После выравнивания координаты совпадают с точностью до 2–3 пикселей.
"answers_left_blank": {
"x": 155, "y_positions": [1390, 1473, ...],
"cols": 17, "x_step": 62,
"cell_width": 61, "cell_height": 68
}
Вырезаем каждую ячейку — получаем 19×17 = 323 маленькие картинки 61×68 пикселей.
Шаг 4. OCR-ансамбль
Для каждой ячейки запускаем три движка параллельно:
- Tesseract с русской и цифровой моделью — надёжен на печатных символах
- PaddleOCR (модель eslav_PP-OCRv5) — хорошо справляется с рукописью
- HOG+SVM классификатор — обучен на 320 вырезанных ячейках, работает мгновенно
Каждый движок даёт гипотезу с весом уверенности. Финальный символ — взвешенное голосование.
Параллельно работают эвристики на форме: looks_like_comma() анализирует связные компоненты пикселей — у запятой два компонента, нижний компонент расположен ниже половины ячейки по Y. Это важно: запятая в числах вроде -2,3 или 0,78 иначе регулярно выпадает.
Шаг 5. Сборка ответа и сверка с ключом
Символы из ячеек одной строки собираются в строку. Применяются правила «лечения»: пустые ячейки между цифрами — скорее всего шум, крайние пустые клетки отсекаются.
Результат сравнивается с ключом ответов (JSON-файл с правильными ответами на все 19 заданий). Для каждого задания — ✅ или ❌.
Три задачи, которые оказались нетривиальными
1. Запятая или мусор?
Ученики пишут -2,3 или 112,75. Запятая — маленький символ в нижней части ячейки. OCR часто путает её с мусором (царапиной, пятном) или вовсе игнорирует.
Мы написали функцию looks_like_comma(), которая анализирует бинарное изображение ячейки:
def looks_like_comma(binary_cell):
components = find_connected_components(binary_cell)
primary = max(components, key=lambda c: c.area)
# Запятая маленькая и расположена ВНИЗУ ячейки
if primary.cy > 0.52 * cell_height # ниже середины
and primary.area < 0.045 * cell_w * cell_h
and primary.w < 0.38 * cell_w:
return True
Дополнительно: если в ячейке два компонента и меньший находится выше большего — это хвостик запятой, верный признак.
2. «1» или «7»?
Ученики пишут цифру «1» с диагональным засечком сверху (как в прописях). OCR видит диагональ и вертикаль — и читает «7».
Мы ввели признак seven_mid_crossbar: настоящая «7» имеет горизонтальную черту посередине (европейский стиль) — если её нет, это скорее «1». Плюс key_bias: когда уверенность низкая, а ключ ответов подсказывает конкретное значение — склоняемся к нему.
В итоге: цифра "7"-образной формы без поперечины → «1». На экране показываем оба варианта: итоговый ответ и маленький значок «OCR: 7» для прозрачности.
3. Выравнивание при перекосе листа
Если бланк сканирован под углом 2–3°, ячейки сдвигаются. При шаге 62 пикселя достаточно 1.5° чтобы к строке 17 координаты уехали на 5–8 пикселей и края ячеек захватывали соседние символы.
Решение: четыре реперных маркера → cv2.getPerspectiveTransform → cv2.warpPerspective. После этого все ячейки стоят на своих местах с погрешностью ≤2 пикселя вне зависимости от наклона исходного скана.
Веб-интерфейс
Всё завёрнуто в Flask-приложение. Учитель открывает браузер, выбирает вариант из выпадающего списка (например «ОГЭ Математика 19.03.2026 — Вариант 4»), загружает скан или PDF — и через 5–8 секунд видит результат:
- ФИО ученика (распознано из верхней части бланка)
- Крупный счёт: «10 / 19 заданий выполнено верно»
- Таблица: задание | ответ ученика | правильный ответ | ✅/❌
- Строки подсвечены зелёным/красным
Если OCR автоматически исправил неуверенно прочитанный символ — рядом показывается маленький значок «OCR: 7» чтобы учитель мог проверить вручную.
PDF с несколькими страницами обрабатывается как пакет: каждая страница — отдельный ученик, итог — ZIP с Excel-файлами на каждого.
Результаты и честные ограничения
На 8 чистых (без посторонних пометок) бланках:
- Выравнивание сработало у всех 8 из 8
- Числовые ответы (без запятых): точность ~95%
- Ответы с запятыми (дроби): ~88% после фикса пороговых значений
- Пустые задания (ученик не ответил): распознаются корректно
Что ещё не идеально:
- Рукописные буквы в ФИО иногда путаются (особенно «Д» и «А» в схожих почерках) — OCR работает на уровне одной ячейки, без контекста соседних букв
- HOG+SVM обучен на 320 примерах — маловато, нужно больше размеченных данных
- Радикал «√2» и очень длинные дроби (8 знаков) — в списке следующих задач
Стек
ЧтоЧемВеб-серверFlask 3.xОбработка изображенийOpenCV, NumPyPDFPyMuPDF (fitz)OCR 1Tesseract + pytesseract (lang=rus+digits)OCR 2PaddleOCR eslav_PP-OCRv5_mobileOCR 3HOG + scikit-learn SVMВыгрузкаopenpyxl → Excel
Исходный код
Проект открыт: github.com/psuren1992-glitch/smart-ocr-lite
В репозитории: весь код пайплайна, шаблоны для 6 вариантов ОГЭ математики 19.03.2026, Flask-приложение, обученная HOG+SVM модель. Данные учеников в репозитории отсутствуют — только пустые директории с .gitkeep.
Для запуска:
cd smart-ocr-lite
pip install -r requirements.txt
python webapp.py
# Открыть http://localhost:5001