Нейросеть для детекции подмены почерка: Python, TensorFlow, сиамская сеть | AiManual
AiManual Logo Ai / Manual.
21 Май 2026 Гайд

Как нейросеть выявляет подмену почерка на экзаменах без эталона: технический разбор на Python

Технический гайд по созданию пайплайна на Python для выявления подмены почерка в экзаменационных работах без эталонного образца. Сиамская нейросеть, сегментация

Студент нанял писца, а работа все равно провалилась

Знаете этот трюк: студент пишет первую страницу сам, а вторую отдает «левому» автору с похожим почерком. Комиссия не замечает, оценка — отлично. Пока нейросеть не сказала: «Стоп, эта буква «а» имеет другой угол наклона и соотношение ширины/высоты». И без единого эталона — просто сравнила соседние участки работы.

В 2026 году, когда антиплагиат уже не прячется за шинглами и векторными моделями, проверка почерка остается серой зоной. OCR читает текст, но не оценит, кто его писал. Классификатору нужен эталон — образец почерка каждого студента. А где его взять? Решение — сиамская нейросеть, которая сравнивает фрагменты работы между собой и находит момент «смены руки».

Кстати, если вы работаете с LLM и тестируете их на локальных моделях, автоматический тест-сьют для локальных LLM сэкономит вам часы гаданий.

1 Почему традиционные методы пасуют?

Допустим, у вас есть скан работы. Попытка применить классификатор (например, ResNet) на всем листе разобьется о вариативность: один студент пишет крупно, другой мелко, третий с наклоном. Без эталонного стиля сеть не сможет отличить «своего» от «чужого». Более того, даже внутри одной работы почерк может естественно меняться (устал, торопился). Задача — не просто классифицировать, а найти аномальный сдвиг стиля, который указывает на подмену.

Еще одна ловушка — бинарный подход: «два разных почерка — да/нет». На практике разница может быть микроскопической: толщина линии на 0.1 мм, изгиб хвостика у буквы «у». Нужны метрики сходства, а не жесткая классификация.

2 Сиамская архитектура: когда эталон — это ближайший сосед

Сиамская нейросеть — это две одинаковые CNN, которые принимают на вход пару изображений (патчей с рукописным текстом) и выдают эмбеддинги. Затем вычисляется расстояние (например, евклидово) между эмбеддингами. Если патчи принадлежат одному почерку — расстояние мало, разным — велико.

Ключевой трюк: эталон не нужен. Мы сравниваем все патчи работы между собой, строим матрицу сходства. Строки, где сходство резко падает с соседними — места подмены. Это как «автокорреляция» в сигналах, только для изображений.

Для тех, кто в теме кодинговых моделей, аналогия с APEX Testing и ELO-рейтингом: мы тоже строим относительную шкалу, а не абсолютную.

3 Пайплайн обработки скана (OpenCV + TensorFlow)

Разберем этапы от сырого скана до матрицы аномалий.

3.1. Предобработка и сегментация строк

Первым делом — избавиться от шума, выровнять наклон (дескев) и выделить строки. Используем OpenCV: бинаризация OTSU, морфология для соединения символов одной строки, затем проекция по горизонтали.

import cv2
import numpy as np

def segment_lines(img_path):
    img = cv2.imread(img_path, 0)
    # Бинаризация: Otsu
    _, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    # Морфологическое закрытие для соединения букв в строки
    kernel = np.ones((5, 50), np.uint8)
    closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
    # Горизонтальная проекция
    h_proj = np.sum(closed, axis=1) // 255
    # Поиск строк
    in_line = False
    lines = []
    for y, val in enumerate(h_proj):
        if val > 0 and not in_line:
            start = y
            in_line = True
        elif val == 0 and in_line:
            end = y
            lines.append((start, end))
            in_line = False
    if in_line:
        lines.append((start, len(h_proj)))
    # Извлекаем ROI строк
    line_imgs = [img[s:e, :] for s, e in lines]
    return line_imgs

Ошибка новичка: не удалять слишком короткие строки (артефакты). Добавьте фильтр по минимальной высоте — например, отбрасывайте строки короче 30 пикселей.

3.2. Нарезка на патчи (patch extraction)

Каждую строку делим на перекрывающиеся квадратные патчи фиксированного размера (например, 64x64). Перекрытие 50% — чтобы не потерять границы букв. Патчи должны содержать текст, а не пустоту — отбрасываем по процентному содержанию пикселей чернил.

def extract_patches(line, patch_size=64, stride=32):
    h, w = line.shape
    patches = []
    for x in range(0, w - patch_size + 1, stride):
        patch = line[:, x:x+patch_size]
        # Фильтр пустых патчей: если белых пикселей < 10% — пропускаем
        if np.mean(patch) > 240:  # фон белый
            continue
        patches.append(patch)
    return patches

3.3. Усиление контуров через Canny

Почерк — это не чернила, а форма штрихов. Canny edge detector вытягивает края, игнорируя цвет и яркость (освещение может скакать). После Canny получаем бинарную карту границ — это вход для нейросети.

def preprocess_patch(patch):
    # Убираем вариации освещения
    blur = cv2.GaussianBlur(patch, (5,5), 0)
    edges = cv2.Canny(blur, 50, 150)
    # Нормализация в 0..1 и ресайз до 64x64 (если патч уже такого размера)
    return edges.astype(np.float32) / 255.0

4 Сиамская сеть на TensorFlow 2.15

Строим lightweight CNN для извлечения эмбеддингов размерности 128. База — три сверточных блока с BatchNormalization и MaxPooling. Важно: веса обеих половин сети одинаковы (shared weights).

import tensorflow as tf
from tensorflow.keras import layers, models

def create_embedding_network(input_shape=(64,64,1)):
    inp = layers.Input(shape=input_shape)
    x = layers.Conv2D(32, (3,3), activation='relu', padding='same')(inp)
    x = layers.BatchNormalization()(x)
    x = layers.MaxPooling2D((2,2))(x)
    x = layers.Conv2D(64, (3,3), activation='relu', padding='same')(x)
    x = layers.BatchNormalization()(x)
    x = layers.MaxPooling2D((2,2))(x)
    x = layers.Conv2D(128, (3,3), activation='relu', padding='same')(x)
    x = layers.BatchNormalization()(x)
    x = layers.GlobalAveragePooling2D()(x)
    x = layers.Dense(128, activation=None)(x)  # без активации
    # L2-нормализация для стабильного обучения
    emb = tf.math.l2_normalize(x, axis=1)
    return models.Model(inp, emb)

Сиамская модель принимает два входа и вычисляет расстояние.

def siamese_network(input_shape):
    base_network = create_embedding_network(input_shape)
    input_a = layers.Input(shape=input_shape)
    input_b = layers.Input(shape=input_shape)
    emb_a = base_network(input_a)
    emb_b = base_network(input_b)
    # Евклидово расстояние
    distance = tf.sqrt(tf.reduce_sum(tf.square(emb_a - emb_b), axis=1, keepdims=True))
    return models.Model([input_a, input_b], distance)

4.1. Контрастивная потеря (Contrastive Loss)

Для обучения пар: одинаковый почерк — 0, разный — 1. Функция потерь — Contrastive Loss: L = (1-Y) * 0.5 * d^2 + Y * 0.5 * max(0, margin - d)^2. Margin берем 1.0. Это гарантирует, что одинаковые патчи сближаются, а разные раздвигаются.

def contrastive_loss(margin=1.0):
    def loss(y_true, y_pred):
        # y_true: 0 — same, 1 — different
        y_pred = tf.squeeze(y_pred)
        y_true = tf.cast(y_true, tf.float32)
        same_part = (1 - y_true) * tf.square(y_pred)
        diff_part = y_true * tf.square(tf.maximum(margin - y_pred, 0))
        return 0.5 * tf.reduce_mean(same_part + diff_part)
    return loss

model = siamese_network((64,64,1))
model.compile(optimizer='adam', loss=contrastive_loss(margin=1.0))

5 Инференс: находим точку смены почерка

После обучения загружаем веса. На скане работы извлекаем все патчи последовательно (одна строка за другой, слева направо). Для каждого патча считаем эмбеддинг (базовая сеть). Затем вычисляем косинусное сходство между соседними патчами в окне (например, патч i и i+5). Если сходство падает ниже порога — метим как «аномалия».

def compute_anomalies(patches, model, window=5, threshold=0.7):
    emb = model.predict(np.array(patches)[..., np.newaxis])
    anomalies = []
    for i in range(len(emb) - window):
        sim = np.dot(emb[i], emb[i+window])
        if sim < threshold:
            anomalies.append(i)
    return anomalies

На практике окно должно быть не менее 3–5 патчей, чтобы не реагировать на случайные колебания. Порог подбирается на валидации — можно использовать процентиль.

Если вы донатили на студенческом стартапе с ИИ-репетитором, вы знаете, как просто ошибиться с порогами уверенности. Аналогичная история.

6 Обучение без эталонов: как собрать датасет

Проблема: нужны пары «один и тот же почерк — разные почерка». Где взять? Вариант — наколхозить синтетический датасет: берем тексты одного человека, режем на строки, внутри одной работы — позитивные пары. Между разными работами — негативные. Если есть доступ к архиву работ (с разрешением), можно нарезать пары. Или использовать открытые датасеты рукописного текста (IAM, CVL), но с осторожностью — шрифты там не всегда подходят для западного начертания.

Я предпочитаю собрать 50–100 работ реальных студентов (анонимно) и нарастить аугментациями: поворот, сжатие, размытие, шум. Это учит сеть игнорировать технические артефакты.

7 Ошибки, которые съедят ваше время

  • Тренировать на целых строках, а не на патчах — переобучение на общем расположении символов, а не на начертании.
  • Не нормализовать эмбеддинги — L2-нормализация обязательна, иначе loss расходится.
  • Игнорировать перекос при сканировании — обязательно deskew через minAreaRect в OpenCV.
  • Слишком малый размер патча — меньше 48x48 теряет контекст буквы, больше 96x96 — сеть начинает «запоминать» расположение, а не почерк.

8 Что дальше: от почерка до подписей

Та же архитектура работает для проверки подписей на договорах — без эталона подписи Иванова можно сравнить подпись на договоре с подписью на заявлении. Или детектировать подделку дипломов. На самом деле, сиамские сети — это универсальный детектор аномалий для изображений с повторяющимся паттерном.

Если вам интересно, как небольшие модели (вроде 4B-параметров) справляются с логикой, загляните в QED-Nano: доказывает теоремы на ноутбуке — там схожие принципы компактной архитектуры.

Совет под занавес: не гонитесь за 99% точности сразу. Лучше поставьте детектор на поток сканов и анализируйте false positives. Первый же прокол на реальном экзамене подорвет доверие к системе. Спокойствие и итерации — вот ваш девиз.

Хотите воспроизвести пайплайн? Попробуйте TensorFlow на 1 клик в облаке (Code Ocean, но можно и локально). А если ищете готовую модель под свою задачу — Hub моделей Hugging Face завален рукописными эмбеддерами.

Подписаться на канал