Недавно мы с научным руководителем задались вопросами: Какая лексика чаще всего встречается в учебнике, а какая появляется всего один раз? Какие упражнения присутствуют чаще – языковые или коммуникативные? Соответствует ли лексика в учебнике заявленному уровню? Сколько всего текстов в учебнике? О чем большинство?

Чтобы дать ответ на все эти вопросы, нам на помощь приходит Python для задач NLP (Natural language processing). Разберем код, решающий задачу форматирования PDF в TXT. Ведь именно хорошо представленные данные позволяют нам проводить качественный и количественный анализ текста. 

Алгоритм следующий:

  1. Загружаем страницу учебника и конвертируем в PDF.

  2. Если изображение плохого качества, препроцессим - улучшая качество(preprocess_image).

  3. Используем EasyOCR для распознавания текста.

  4. Собираем текст в блоки с номером страницы.

  5. Если страница “Битая” - обрабатываем ошибки.

Для того, чтобы наш результат был максимально точным используем OCR (Optical Character Recognition), предназначенного для оптического распознавания текста, даже самый «сложный» PDF-учебник можно быстро превратить в текстовый файл.

Для чего это нужно:

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

  • Разработчик видит пример работы с PDF, изображениями, OCR и многопоточностью.

  • Методист получает инструмент для проверки учебников и сопоставления лексики между изданиями.

Реализация

Для начала необходимо импортировать библиотеки для работы с файлами, потоками, временем (os, io, time), а также библиотеку concurrent.futures для многопоточных задач, а также: numpy, cv2 (про open cv и его возможности). Для работы с PDF необходимы fitz и easyocr для извлечения текстов с картинок и PIL для работы с изображениями.

Пример для Visual Code (version 3.11.9.).

Начнем с конфигурации:

#Конфигурация
PDF_PATH = r"C:\Учебники\Кузовлев_3кл.pdf" # путь к учебнику
OUTPUT_TXT = os.path.join(os.path.dirname(PDF_PATH), "kuzovlev_output.txt")
LANGUAGES = ['en', 'ru'] 
THREADS = 4  # количество потоков. можно менять числа, но распознавание не особо улучшается 
USE_GPU = True #GPU для ускорения обработки
PREPROCESS_IMAGES = True #улучшение качества картинок

Препроцессинг изображения

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

Важно: Если скан бледный(ура, у учебников это в большинстве случаев), то пиксели занимают узкий диапазон 50–180 и буквы не четко отделяются. Растяжение даёт полный 0–255 диапазон, повышая контраст. Однако возможна ошибка деления на ноль, если img.max() == img.min() (однотонная картинка). Поэтому необходима нормализация яркости— линейное растяжение контрастного диапазона (contrast stretching).
Альтернативы: cv2.normalize, skimage.exposure.rescale_intensity, CLAHE (локальное усиление контраста).

#Улучшение качества изображения
def preprocess_image(img):
    img = img.convert('L') # перевод в градации серого
    img = img.resize((img.width  2, img.height  2), Image.LANCZOS) # масштабирование ×2 с сохранением качества
    img = np.array(img) #необходим перевод PIL.Image в NumPy-массив, потому что OpenCV функции работают с массивами.
    # нормализация яркости (растяжение контраста до 0–255)
    img = img.astype(np.float32)
    img = (img - img.min()) * (255 / (img.max() - img.min()))
    img = np.clip(img, 0, 255).astype(np.uint8)
    # бинаризация (OTSU подбирает оптимальный порог)
    img = cv2.threshold(img, 0, 255, cv2.THRESHBINARY + cv2.THRESH_OTSU)
    return Image.fromarray(img)

Конвертация PDF в текст

Функция обработки страницы  отвечает за извлечение текста с одной страницы PDF. Она загружает страницу по номеру и конвертирует её в изображение с высоким разрешением (300 DPI), затем превращает это изображение в пискельный объект. При включённой опции предобработки изображение улучшается: переводится в градации серого, масштабируется, нормализуется яркость и бинаризуется методом Оцу (об этом читать выше) для лучшего распознавания. Далее изображение передаётся в EasyOCR, который распознаёт текст блоками, объединяет строки в абзацы и игнорирует мелкие элементы.

Функция возвращает текст с нумерацией страниц и аккуратно обрабатывает возможные ошибки, чтобы скрипт не прерывался на проблемных страницах.

#Обработка одной страницы
def process_page(args):
    page_num, doc, reader = args
    try:
        page = doc.load_page(page_num)
        pix = page.get_pixmap(dpi=300)
        img_data = Image.open(io.BytesIO(pix.tobytes("png")))
        if PREPROCESS_IMAGES:
            img_data = preprocess_image(img_data)
        result = reader.readtext(np.array(img_data),
                                 batch_size=10, #обрабатываем блоками для ускорения
                                 detail=0, #возвращаем только текст, без координат
                                 paragraph=True, #paragraph=True — объединяем строки в абзацы
                                 min_size=10, #min_size=10 — минимальный размер текста для распознавания 
                                 contrast_ths=0.3) #порог чувствительности к контрасту
        return f"\n=== Страница {page_num+1} ===\n" + "\n".join(result)
    except Exception as e:
        return f"\nОшибка на странице {page_num+1}: {str(e)}"

Основной код

Спойлер: В начале инициализируется OCR-модель и открывается документ, затем подсчитывается количество страниц. Обработка страниц выполняется параллельно через ThreadPoolExecutor (класс из модуля concurrent.futures), где каждая страница распознаётся предыдущей функцией process_page. Результаты собираются в список, сохраняются в текстовый файл и выводится прогресс обработки. В конце показывается затраченное время и средняя скорость распознавания страниц. 

#Главная функция
def main():
    #Запоминаем время начала выполнения программы для последующего расчета времени обработки
    start_time = time.time()
    
    #Выводим сообщение о начале инициализации EasyOCR
    print("Инициализация EasyOCR...")
    
    #Создаем объект reader для распознавания текста с использованием EasyOCR
    # LANGUAGES - список языков, которые будут использоваться для распознавания
    # USE_GPU - флаг, указывающий, использовать ли GPU для ускорения обработки
    # model_storage_directory - путь к директории хранения модели (None означает использование стандартной)
    # download_enabled - если True, разрешает автоматическую загрузку моделей (False отключает)
    reader = easyocr.Reader(LANGUAGES,
                            gpu=USE_GPU,
                            model_storage_directory=None,
                            download_enabled=False)


    #Открываем PDF-документ по заданному пути
    doc = fitz.open(PDF_PATH)
    
    #Получаем общее количество страниц в документе
    total_pages = len(doc)
    print(f"Начало обработки {total_pages} страниц...")

    #Список для хранения результатов обработки страниц
    results = []
    
    #Создаем пул потоков для параллельной обработки страниц
    with concurrent.futures.ThreadPoolExecutor(max_workers=THREADS) as executor:
        #Отправляем задачи на обработку каждой страницы в пул потоков
        futures = [executor.submit(process_page, (page_num, doc, reader))
                   for page_num in range(total_pages)]


        #Обрабатываем завершенные задачи по мере их завершения
        for i, future in enumerate(concurrent.futures.as_completed(futures), 1):
            #Получаем результат обработки страницы и добавляем его в список результатов
            results.append(future.result())
            #Выводим информацию о количестве обработанных страниц, обновляя строку в терминале
            print(f"Обработано {i}/{total_pages} страниц", end='\r')


    #Открываем файл для записи результатов в текстовом формате с кодировкой UTF-8
    with open(OUTPUT_TXT, "w", encoding="utf-8") as f:
        #Записываем все результаты, разделенные новой строкой
        f.write("\n".join(results))

    #Вычисляем общее время выполнения программы
    elapsed = time.time() - start_time
    
    print(f"\nГотово! Время обработки: {elapsed//60:.0f} мин {elapsed%60:.2f} сек")
    print(f"Средняя скорость: {total_pages/(elapsed/60):.1f} страниц/мин")
    print(f"Результат сохранен в: {OUTPUT_TXT}")
#Проверяем, что этот файл запускается как основная программа
if __name__ == "__main__":
    main()  

Итого

Так, Python, EasyOCR и простые приёмы предобработки изображений позволяют превратить даже PDF-учебник в удобный текстовый файл для дальнейшего лингвистического анализа (частота встречаемости лексики, типы упражнений, составление учебных корпусов текстов и т.д.). 

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

Ссылки на нас: Git проекта , EduText Analyzer , Тг-канал

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


  1. onkruglikov
    01.09.2025 15:53

    Удивительно, у нас одинаковая фамилия и я тоже пишу на python. Кажется, что это судьба


  1. OlegZH
    01.09.2025 15:53

    Ужас. Срочно обязать всегда хранить текстовый слой. Всех. Поголовно.


  1. nilske
    01.09.2025 15:53

    fitz это сильно устаревшее название библиотеки, но его всегда упорно советует ИИ, который видимо и является автором вашего кода. И эта библиотека имеет встроенную поддержку OCR, основанную на Tesseract.


    1. kruglikle Автор
      01.09.2025 15:53

      Спасибо, fitz - это модуль pymupdf, оставшийся для совместимости. импортировать можно как его, так и pymupdf. Для ocr мы используем easy ocr