Добавить в корзинуПозвонить
Найти в Дзене

Как мы научили Python проверять экзаменационные бланки ОГЭ: от идеи до рабочего веб-сервиса

Реальный проект: сканер ответов, который заменяет учителю ручную проверку 19 заданий части 1 Представьте: у учителя математики — 30 учеников, у каждого заполненный «Бланк ответов №1». В нём 19 заданий с числовыми ответами, записанными по клеткам (как в кроссворде). Каждый символ — в отдельной ячейке: цифры, запятая, знак минус, дробная черта. Учитель берёт бланк, сравнивает строку за строкой с ключом ответов, проставляет 0 или 1 балл. 30 бланков × 19 заданий = 570 ручных проверок. На это уходит около часа. Мы решили это автоматизировать: загружаешь скан — получаешь таблицу с ✅ и ❌ по каждому заданию. Бланк ОГЭ — лист А4 на 300 DPI (2480×3508 пикселей). В нём две колонки по 17 + 2 строки. Каждая строка — одно задание. Каждый символ ответа записан в отдельную ячейку размером ~60×68 пикселей. По углам бланка — четыре угловых маркера (реперные точки): маленькие чёрные квадраты в строго фиксированных позициях. Они нужны для выравнивания: ни один сканер не кладёт бумагу идеально ровно, всегд
Оглавление

Реальный проект: сканер ответов, который заменяет учителю ручную проверку 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