Всем привет, это моя первая статья на Хабре. В этой статье я хочу рассказать, как сгенерировать датасет печатных букв с помощью .ttf файла и кода на Python в 170 строк.

Зачем?

Для начала выясним, зачем нужно генерировать датасет. В моем случае стоял пользовательский интерес в распознавании шифров с конструкторских документов при сканировании чертежей. Для распознавания нужна нейросеть, чтобы обучить нейросеть нужен датасет. В общем, нейросеть без датасета как машина без колес, а значит необходимо иметь возможность генерировать датасет. При попытке найти датасет по типу EMNIST было обнаружено, что печатные шрифты никто не выкладывает, по крайней мере, на Кириллице. А если нужно больше данных чем есть, или нужно поменять искажения текста. Так и было принято решение написать генератор датасета по типу EMNIST. Но с печатными буквами русского алфавита.

Создание изображения

Начнем с библиотек, нам потребуется следующий инструментарий:

from PIL import Image, ImageDraw, ImageFont, ImageFilter

import os

from glob import glob

import numpy as np

PIL - он же Pillow, позволит нам создавать изображения и работать с ними.
OS - позволит работать с директориями.
glob - для поиска изображений в папках.
numpy - для работы с массивами.

Я работаю в PyCharm, поэтому не буду затрагивать тему установки библиотек. Об этом можно почитать на официальном сайте Python.

Что мы имеем на входе?

На входе нам даны следующие параметры:

FONT_PATH = "GOST2304A.ttf"  # Путь к файлу шрифта (поддерживающему кириллицу)

FONT_SIZE = 36  # Размер шрифта 

IMAGE_SIZE = (28, 28)  # Размер выходного изображения

BACKGROUND_COLOR = (255, 255, 255)  # Белый фон

TEXT_COLOR = (0, 0, 0)  # Черный цвет текста

NUM_IMAGES = 10  # Количество изображений на букву

# Список русских букв

RUSSIAN_LETTERS = [

    'А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ё', 'Ж', 'З', 'И', 'Й',

    'К', 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф',

    'Х', 'Ц', 'Ч', 'Ш', 'Щ', 'Ъ', 'Ы', 'Ь', 'Э', 'Ю', 'Я'

]

Как видно из кода, мы можем регулировать следующие параметры:

  • шрифт

  • размер шрифта

  • размер картинки (в пикселях)

  • цвет фона (в RGB)

  • цвет текста (в RGB)

  • количество экземпляров каждой буквы в нашем будущем датасете.

  • Алфавит для генерации

3. Дальше напишем функцию для генерации картинок, на вход она будет принимать только букву и путь до шрифта:

def create_letter_image(letter, font_path):

    """Создает изображение с наклонной буквой"""

    # Загружаем шрифт

    try:

        base_font = ImageFont.truetype(font_path, FONT_SIZE)

    except OSError as e:

        print(f"Ошибка загрузки шрифта: {font_path}")

        print(e)

        return

    # Создаем временное изображение для измерения текста

    temp_image = Image.new('RGB', IMAGE_SIZE, BACKGROUND_COLOR)

    draw = ImageDraw.Draw(temp_image)

    text_width, text_height = draw.textbbox((0, 0), letter, font=base_font)[2:]

    # Создаем основное изображение с учетом наклона

    image = Image.new('RGB',

                      (int(IMAGE_SIZE[0]), IMAGE_SIZE[1]),

                      BACKGROUND_COLOR)

    draw = ImageDraw.Draw(image)

    # Рисуем текст с наклоном

    x = (image.width - text_width) / 2

    y = (image.height - text_height) / 2

    draw.text((x, y), letter, font=base_font, fill=TEXT_COLOR)

    return image

Сначала проверяем наличие шрифта в нашем проекте.

Создаем новое изображение с помощью Image.new. Задаем цветовой канал, размер изображения и цвет фона.

С помощью метода textbbox создадим поле для написания текста и зададим высоту и ширину поля.

Задаем координаты центра буквы и заполняем текстовое поле взяв букву из нашего алфавита.

Добавляем шумы

Для создания уникальных изображений и во избежание повторов при генерации и как следствии получении не репрезентативной выборки создадим функцию, которая будет генерировать шум на изображении случайно добавляя пиксели разной яркости (и здесь мы будем конвертировать нашего изображение из RGB в оттенки серого).

def add_noise(image):

    """

    Добавляет гауссов шум на изображение в оттенках серого

    (шум одинаков для всех каналов, сохраняя изображение серым)

    """

    # Конвертируем в numpy array и преобразуем в grayscale

    img_array = np.array(image.convert('L'))  # 'L' - режим оттенков серого

    height, width = img_array.shape

    noisy = img_array.copy()

    # Параметры гауссова шума

    mean = 0

    var = np.random.uniform(0.001, 0.02)  # Диапазон дисперсии

    sigma = var ** 0.5

    # Генерируем шум (один канал)

    gauss = np.random.normal(mean, sigma, (height, width))

    # Применяем шум и обрезаем значения

    noisy = np.clip(noisy + gauss * 255, 0, 255).astype(np.uint8)

    # Конвертируем обратно в RGB (но сохраняем оттенки серого)

    return Image.fromarray(noisy).convert('RGB')

Было использовано гауссовское распределение для отрисовки серых пикселей случайной яркости.

Итоговая функция

Итоговая функция будет выглядеть следующим образом:

def main():

    # Загружаем шрифт

    try:

        font = ImageFont.truetype(FONT_PATH, FONT_SIZE)

    except IOError:

        print(f"Ошибка: Шрифт по пути '{FONT_PATH}' не найден")

        return

    # Создаем корневую директорию

    for letter in RUSSIAN_LETTERS:

        # Создаем директорию для буквы

        letter_dir = f"Generated_images/Letter_{letter}"

        os.makedirs(letter_dir, exist_ok=True)

        # Генерируем эталонное изображение

        # Сохраняем NUM_IMAGES копий

        for i in range(NUM_IMAGES):

            img = create_letter_image(letter, FONT_PATH)

            img = add_noise(img)

            file_name = f"Letter_{letter}_{i:04d}.png"

            file_path = os.path.join(letter_dir, file_name)

            img.save(file_path)

        print(f"Сгенерировано {NUM_IMAGES} изображений для буквы '{letter}'")

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

Итог

Надеюсь этот пример будет полезен тем, кто хочет обучать свои небольшие нейросети для узких задач, но кто упирается в отсутствие нужных датасетов. При желании можно добавить функцию деформации изображения для улучшения распознавания, например, наклонных отсканированных документов, но это уже будет описано в следующей статье.
Подробный код можно скачать с моего GitHub.

Комментарии (3)


  1. CrazyElf
    04.07.2025 12:54

    Хотел в своё время с чем-то таким поэкспериментировать, да так руки и не дошли. По идее это что-то близкое к аугументации. Имеет смысл попробовать не только зашумление, но и всякие другие искажения - небольшие повороты, наклоны, изменение яркости, много там чего можно придумать. Но степень полезности искажений будет зависеть от конкретной дальнейшей задачи, конечно - смотря какие там могут встретиться искажения.


    1. PoStM0DeRn Автор
      04.07.2025 12:54

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


  1. Emelian
    04.07.2025 12:54

    как сгенерировать датасет печатных букв с помощью .ttf файла и кода на Python

    Идея, конечно, интересная, ее можно будет принять к сведению. Я тоже, сейчас, занимаюсь похожей темой, но меня интересует более полное решение следующей задачи: «Дана ссылка на канал с видео на Ютубе, нужно распознавать встроенные двуязычные субтитры на них, в данном случае, на французском и английском языках». На базе этих данных я буду готовить уроки для своей обучающей программы.

    Хорошего и бесплатного решения, с помощью ИИ-сервисов, я не нашел, поэтому, пишу собственный скрипт на Питоне.

    Здесь, я хочу обойтись без датасетов и обучения нейросетей. Все интересующие видео имеют фиксированные области для каждой строки с текстом и один и тот же шрифт, правда, разных размеров. Это позволяет выделить, построчно, весь текст, преобразовать его в бинарные матрицы и, затем, разделить символы по криволинейному контуру между ними. После чего, можно приступать к процедуре распознавания этих символов, на базе тех или иных метрик.

    Да, приходится, вручную, устанавливать соответствие между символами и их ключевыми параметрами, но, это работа обозримая и я уже распознал, таким образом, несколько десятков строк из начала выбранного ролика.

    Нюансы здесь в том, что одному символу может соответствовать некоторое множество параметров, которые надо, вручную, прописывать в моей функции выбора. Со временем, подумаю, как этот процесс автоматизировать, а, пока, неплохо и так.

    Соответственно, мне не нужно «зашумлять» исходные символы. Вполне хватает естественных дефектов. Изредка, встречаются артефакты, связанные с выбором порогового значения для лямбда-функции, участвующей в создании бинарных матриц.