OCR · Компьютерное зрение · LLM-верификация

Как мы превратили скан словаря ударений в машинный словарь

Как из PDF-подобного DjVu-скана 2001 года получить 34 тысячи форм с ударениями, при том что OCR принципиально не читает знаки ударения — и проверить результат так, чтобы не испортить его «умными» правками.

  • 121 170токенов OCR
  • 57 741знак ударения найдено
  • 34 217форм в словаре
  • 0потерянных знаков
x,y,w,h осколок

О чём эта статья на самом деле. Здесь разбирается не словарь, а приём: как превратить в данные любой печатный источник, который сохранился только как картинки. Рецепт один, а словарь ударений 2001 года — просто особенно трудный случай, на котором этот приём обкатывали и отлаживали.

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

Почему в проекте понадобился чужой словарь — и почему он оказался таким трудным

Telegram-боту нужно уметь произнести имя и распознать речь. Обе задачи упираются в ударения, но по-разному. Синтезатор речи (TTS — text-to-speech) берёт слово и превращает его в звук; если он не знает, где ударение, он выбирает ударение по правилам языка. Для фамилии вроде «Аге́нко» правило обычно срабатывает, а для «Аба́ва» или «Магада́н» — нет, потому что современное слово так не произносится, а словарь 2001 года произносил иначе.

Собранного ударения у проекта было много: 3,2 млн словоформ в основной базе плюс модели silero-stress, RUAccent, properpouns. Но всё это — общеязыковые слова. Именные формы в них живут отдельной маленькой вкладкой, а систематического русского словаря имён с ударениями в открытом доступе до сих пор просто нет.

Кандидат нашёлся, но выглядел подозрительно: «Собственные имена в русском языке. Словарь ударений», НЦ ЭНАС, 2001 год, 376 страниц, около 35 тысяч единиц. Почти никто его не использует, и на то есть причина, которую честнее назвать сразу:

  • это скан, а не PDF с текстовым слоем — лежит .djvu на 4 мегабайта, и внутри нет ни байта распознанного текста;
  • словарь набран в две колонки мелким шрифтом на пожелтевшей бумаге;
  • и главное — правильное написание каждой единицы и есть её ударение. Для обычного текста это деталь оформления, здесь — половина смысла.

Читать такой источник можно только распознаванием изображений, а распознавание изображений называется OCR. С этого момента начинается собственно интересная часть работы.

Зачем это вообще нужно

Сначала о главном: кто за что отвечает в этом пайплайне

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

СлойЗа что отвечаетЧто это
tesseractбуквынейросеть (LSTM), обученная на обычном тексте
CV-детекторзнаки ударенияобычный Python без нейросети: порог, связные компоненты, геометрия
Фильтр приёмарешение «верить правке или нет»правила, сравнивающие правку с сигналом CV
LLMто, что CV признал неразрешимымнейросеть, которая смотрит на кропы строк

Три следствия, которые стоит запомнить:

  • Нейросеть в пайплайне ровно две — tesseract на буквах и LLM на спорных местах. Самое важное, то есть поиск знаков ударения, делает обычный код.
  • Нейросеть не «размечает» ударения, а уточняет то, что код признал неразрешимым. Основную массу форм правит детерминированная починка букв — это просто таблица правил.
  • LLM не является источником истины. Каждая её правка сверяется с независимым сигналом CV. Поэтому проверять можно не «похоже на правду», а формально.

По ходу статьи пояснения к этому разложению вставлены прямо по месту — в блоках «Простыми словами». Их можно свернуть, если читать хочется быстро.

Коротко: без словаря имён с ударениями бот произносит фамилии наугад. Источников ударений у проекта и без того было много (accents_index.sqlite на 3,2 млн словоформ общего словаря и 119 тыс. имён, плюс silero-stress, RUAccent, properpouns), но имена собственные жили там отдельной маленькой вкладкой, а систематического русского словаря имён в открытом доступе до сих пор просто нет.

Качество синтеза здесь упирается в ударения напрямую: «Зе́леноград» и «Зеленогра́д» — это разные слова и для TTS, и для слушателя. Синтезатор без подсказки поставит ударение по правилам языка, а для фамилии это правило часто не то.

Конкретный источник: «Собственные имена в русском языке. Словарь ударений», изд. НЦ ЭНАС, 2001, 376 с., ~35 000 словарных единиц (Агеенко) — почти не используемый, и по причинам, названным выше.

Книга есть в виде DjVu-скана: 187 разворотов, 3241×2368 px @300 dpi, 4 МБ. Текстового слоя нет вообще — djvutxt возвращает 0 байт. То есть это честная OCR-задача, причём худший её вариант: словарь, где правильное написание каждой единицы — это её ударение.

Ниже — инженерный разбор того, как это было сделано и почему именно так.

Этап 0. Разведка: что вообще можно вытащить

Прежде чем писать пайплайн, мы проверили гипотезу «просто дать tesseract'у картинки» — в четырёх вариантах препроцессинга (как есть, оттенок серого, бинаризация по порогу, адаптивная бинаризация), с языками rus, rus+eng, моделью tessdata_best, --psm 6 и --psm 4.

Результат: 0 знаков U+0301 во всех вариантах. Ни в одной комбинации параметров.

Простыми словами: почему OCR вообще не умеет читать ударения

Сначала о том, что вообще такое OCR. Это программа, которая смотрит на картинку и пытается превратить её в текст. Проще всего представить её так: она перебирает варианты, что написано в данном месте, и выбирает тот, который похож на картинку. Для этого у неё есть словарь примеров — огромный список того, как слова обычно выглядят на бумаге.

И вот здесь ключевое. Чтобы программа научилась узнавать, скажем, букву «а», ей показывали тысячи картинок, где она написана. Букву с ударением сверху в этот перечень почти никогда не добавляли — зачем, если в обычных текстах такие знаки редки?

Получается занятный эффект: ударение в глазах читателя — это маленький штрих, ошибка в одну букву, мелочь, на которую можно и не посмотреть. А для машины это символ, которого в её словаре просто нет. Она не «не заметила» штрих — она не знает, что это за штрих, и потому не может его произнести. Мы проверили это на увеличенных фрагментах: знак там виден невооружённым глазом, он просто никак не доезжает до текста.

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

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

Отсюда архитектурное решение: OCR отвечает за буквы, компьютерное зрение — за знаки.

DjVu-скан187 разворотов
Рез на колонки742 колонки
OCR + TSV121 170 токенов
CV-детектор57 741 знак
Починка буквй→и́, ё→е́, б→о́
Авто-фильтр38 % токенов
LLM по кропам231 батч
Словарь34 217 форм
Порядок обязателен: CV-детектор должен отработать до починки букв, иначе восстанавливать букву придётся гаданием. Подсвечены два дорогих слоя — всё остальное детерминировано.

Второй важный вывод разведки: tesseract не просто «пропускает» ударение, он портит букву.

В печатиЧто выдаёт tesseractКомментарий
и́йакут выглядит как кратка у й
а́&два штриха «срослись» в амперсанд
е́ёакут превратился в «две точки над е»
о́бакут + «о» склеились в «б»
СЕНГО́РCEHI'OPКАПС-кириллица прочитана латиницей, акут → апостроф

Это принципиально важно для дизайна: если знака ударения нет, восстановить букву нельзя — придётся гадать. Если знак есть (найден CV-детектором), букву восстановить можно: раз й оказалась в ячейке с акутом, значит это и́.

Именно поэтому порядок «OCR → починка» без CV-детектора принципиально хуже, чем «OCR → CV-детектор → починка».

Простыми словами: почему нельзя просто догадаться, что там напечатано

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

Отсюда неочевидное, но важное следствие. Без ударения восстановить букву невозможно в принципе. В слове может стоять настоящая «й» (Гайд, Йошикава), настоящая «ё» (Гёте), настоящая «б» — и глазу они неотличимы от своих искажённых версий. Пришлось бы угадывать, а угадывание на 121 тысяче слов гарантированно испортит часть правильных записей.

С ударением всё иначе. Если детектор сказал «в этой клетке есть знак», то вопрос «какая здесь буква» уже не открытый: раз перед нами й, а в клетке акцент, значит это не й, а и́ — печатный облик й как раз и есть «и с краткой сверху». Появление знака не добавляет информации, а снимает неоднозначность.

Отсюда порядок операций, который нельзя менять местами: сначала найти знаки, потом чинить буквы. Найти знаки — это задача компьютерного зрения, а не текстового распознавания, и именно поэтому в пайплайне два отдельных слоя.

Этап 1. Изображения: почему 300 dpi и почему развороты

DjVu в этом файле — это уже растр, а не векторная графика: ddjvu -format=tiff -page=N даёт картинку 3241×2368 px, 300 dpi. Апскейлить нечего, и это хорошо: качество максимально из доступного.

центральный жёлоб просвет просвет 1 разворот
Разворот режется по центральному «жёлобу» (полоса тени переплёта, видна на проекции яркости), затем каждая страница — по белому просвету на 2 колонки. Просвет ищется в диапазоне центр ±15 % ширины; не нашёлся — делим по геометрии.

Разворот (2 печатные страницы) режется по центральному «жёлобу» — вертикальной полосе тени переплёта, которую видно на проекции яркости. Затем каждая страница режется на 2 колонки по белому просвету. Итого 187 разворотов → 742 колонки.

Почему колонки, а не страницы: словарь набирается в две колонки, строки в них короткие, а tesseract при работе с узкой колонкой (≈780 px шириной) ошибается заметно меньше, чем с полосой набора шириной 1600 px, где он «плывёт» по межсловным интервалам.

Колонки режутся жёстко, но с двумя страховками, потому что в развороте бывают поля с неравномерной версткой:

Что здесь можно заменить: Surya как альтернатива нарезке и как второй распознаватель

Этот этап — единственное место в пайплайне, где готовые инструменты могут заметно сэкономить работу, поэтому стоит разобрать отдельно. Заменять ничего не нужно: пайплайн работает и покрывает все 187 разворотов. Речь об альтернативном пути, который короче и убирает часть ручной эвристики.

Что такое Surya. Открытый OCR-движок от Datalab-to. Модель на 650M параметров, которая обрабатывает страницу целиком и возвращает её как HTML, плюс отдельные модели для разметки вёрстки и для поиска строк текста. Заявлена поддержка 90+ языков, русский в их внутреннем бенчмарке — 88,8 %. Код под Apache 2.0, веса под AI Pubs Open Rail-M. Модель из класса document OCR, а не распознавание сцен: на фотографиях и картинках из жизни её цель — не работать.

Ударения она не прочитает

Это главное и сразу обрывает половину ожиданий. Surya — та же VLM, что и наш LLM-этап: она выдаёт текст страницы, обученная на документах. Если в обучающей выборке нет размеченных знаков ударения, предсказывать их она не станет. То есть этап 0 и CV-детектор остаются обязательными — замена OCR здесь невозможна, и наша главная находка никуда не девается.

Есть и тревожная деталь в их же метриках. Они публикуют разбивку по типам источников, и там:

Категория источникаPass rate
Заголовки и колонтитулы92,5 %
Мелкий шрифт93,7 %
Документы на нескольких языках82,4 %
Таблицы86,6 %
Старые сканы41,8 %

Наш источник — пожелтевший скан словаря 2001 года, то есть ровно та категория, где они слабее всего. Смешанная вёрстка словаря (колонки, колонтитулы, разнородные статьи) их картину не исправляет.

Что она даёт: этап 1 целиком

Вот тут выигрыш настоящий. Вся наша нарезка — это ручная геометрия, которую пришлось писать самим: центральный жёлоб по проекции яркости, белый просвет в диапазоне ±15 %, фолбэк «делим пополам». У Surya всё это из коробки:

  • layout-модель сама находит колонки, заголовки, колонтитулы и порядок чтения; метки PageHeader, PageFooter, SectionHeader — то есть наши колонтитульные эвристики заменяются классификацией;
  • поиск вертикальных линий — то есть центральный «жёлоб» находится моделью, а не порогом по яркости;
  • полигоны вместо прямоугольников. Мы работаем с осевыми bbox, а для скана с перекосом страницы полигон точнее. У нас это не использовано, но на других источниках может пригодиться.

После этого этап 1 сводится к ddjvu -format=tiff и вызову Surya — вся остальная логика резания исчезает. Исчезает же и ручной отсев передней части книги и колонтитулов.

И почему всё-таки не замена

Здесь плата, и она больше выигрыша. tesseract в TSV отдаёт координаты каждого слова. Наш шаг 3.7 делит ширину токена между буквами пропорционально делению, и без точной word-level геометрии вся привязка знаков к буквам разваливается. У Surya схема другая:

  • layout отдаёт полигон блока;
  • surya_detect — bbox строки;
  • OCR отдаёт HTML без координат слов.

Чтобы вернуть геометрию уровня слова, придётся либо прогонять OCR per-block и разбирать HTML обратно, либо самостоятельно резать строки. Выигрыш на этапе 1 обменивается на реальную работу по восстановлению координат на этапе 2 — а это как раз то, ради чего мы когда-то перешли от готового текста к разбору TSV.

Куда её встроить можно без переделки

Как второй распознаватель. Её прочтение наверняка местами чище наших й→и́ и о́→б — а местами хуже. Но это не помеха, а материал: расхождение между двумя независимыми распознавателями само по себе становится признаком подозрения, по той же схеме, что у нас уже работает на CV-сигнале. Модель предлагает, дешёвый фильтр сверяет — только теперь «моделей» две, и они не зависимы друг от друга.

Конкретно это означало бы новую причину в таблице этапа 5 наподобие ocr_disagree с высоким приоритетом: токен попадает в очередь на NN, если Surya и tesseract разошлись в буквах. По нашей же статистике по таким токенам и измеряется, сколько работы это добавит.

Цена вопроса: железо и лицензия

УсловиеЗначение
Железо650M параметров: vLLM на GPU либо llama.cpp на CPU / Apple Silicon
Скорость на Apple Silicon≈0,108 страницы/с — около 9 с на разворот, ~30 мин на книгу
Скорость на GPUдо 5,35 страницы/с на RTX 5090, то есть минуты на всю книгу
Памятьдетектор строк работает на torch, остальное требует поднятого бэкенда
Лицензия кодаApache 2.0
Лицензия весовAI Pubs Open Rail-M: бесплатно для исследований и стартапов до $5M выручки, шире — отдельная лицензия

По железу вопрос не тяжёлый: наш pipeline и так CPU-only, а 30 минут на Mac укладываются в разумные рамки. А вот лицензию стоит проверить до любых экспериментов — для коммерческого бота это гейт, а не формальность.

Как проверить, не переделывая ничего

Проверка на тех же 35 колонках, что и для детектора, займёт около часа работы и сразу покажет, есть ли смысл встраивать. Нужно измерить три вещи:

  • на скольких токенах Surya читает буквы лучше tesseract, на скольких хуже;
  • как часто и в какую сторону расходится алфавит — у нас уже была отдельная проблема с КАПС-кириллицей, прочитанной латиницей (CEHI'OP вместо СЕНГО́Р);
  • сколько реально даёт разметка вёрстки против нашей геометрии: не по числу колонок, а по тому, совпадают ли границы.

Если расхождение окажется заметным — добавляется одна причина в фильтре этапа 5, и весь остальной конвейер остаётся нетронутым. Если окажется нулевым, не повреждается ничего: эксперимент стоил часа и был отрицательным результатом, что тоже полезно.

Простыми словами: разворот, dpi и почему резать выгоднее, чем отдавать целиком

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

Dpi — «точек на дюйм», то есть насколько мелко отсканирована бумага. Здесь 300 dpi: это стандартная для книжного скана величина, означающая, что одна печатная точка превратилась в квадрат 1/300 дюйма. Число 3241×2368 — это ширина и высота страницы в пикселях, то есть примерно 7,7 миллиона точек на один разворот.

Тут же важное отличие от привычного: файл — это уже растровая картинка, а не векторная графика. Например, файл PDF может хранить не шрифт, а описание линий и кривых, и тогда картинку можно отрендерить в любом качестве, хоть в 600 dpi. Сканы так не умеют: пиксели уже записаны, и их количество не увеличится. Поэтому увеличивать изображение бессмысленно — лучшего качества, чем оно есть, из файла не получить.

Дальше — чисто про нарезку. Разворот — это две печатные страницы рядом, как в открытой книге. Дальше каждая страница режется на две колонки, потому что словарь набран в две колонки, а между ними идёт белый просвет, и по нему резать проще всего. 187 разворотов превращаются в 742 колонки.

Почему так мельчить полезно: программа распознавания гораздо увереннее работает с короткими строками. На узкой колонке шириной около 780 пикселей ей не из чего запутаться, а на полосе вдвое шире она начинает «плыть» по межсловным интервалам и склеивать соседние слова.

Этап 2. OCR и разбор TSV

bash
tesseract <колонка> - -l rus+eng --psm 6 tsv

--psm 6 — «единый блок», для колонки это лучший режим: он не пытается искать «страницу», а разбирает её как один текстовый блок. TSV даёт по каждому слову: текст, уровень блока/абзаца/ строки, координаты и confidence — то самое число, которое нам потом пригодилось.

Мы не берём готовый текст, а разбираем TSV в собственную структуру: строки (по ключу block/par/line), токены с координатами и conf. Это принципиально: нам нужна не только строка текста, но и геометрия каждого слова — именно по ней потом распределяются знаки ударения.

Простыми словами: зачем вместо текста брать TSV с координатами

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

Поэтому мы попросили распознаватель отдать результат в формате TSV — таблице с разделителями в виде табуляции, по одной строке на каждое слово. В каждой строке:

  • уровень вложенности: block (блок), par (абзац), line (строка) — по этим трём числам строки собираются обратно в абзацы;
  • четыре числа — координаты рамки слова: где начинается и где кончается по горизонтали и по вертикали, в пикселях;
  • сам текст слова;
  • confidence — насколько программа сама уверена в прочтении, от 0 до 100.

Значение confidence мы потом использовали как отдельный признак: если распознаватель сам сказал «я тут не уверен», это повод отправить слово на дополнительную проверку, даже если все остальные признаки спокойны. Для фамилий, кстати, низкая уверенность — норма, а не тревога: имена в русском языке не пересекаются со словарём, и у распознавателя просто нет контекста, на который он мог бы опереться.

Аргумент --psm 6 — это указание «считай, что перед тобой один цельный блок текста, а не страница журнала». Для колонки это ровно то, что нужно.

Этап 3. Детектор знаков ударения (компьютерное зрение)

Это ядро всей работы. Задача: на бинарном изображении колонки найти все наклонные штрихи над гласными, отбросив всё остальное.

3.1. Бинаризация

python
mask = np.asarray(Image.open(path).convert("L")) < 170

Один глобальный порог. Соблазнительно взять адаптивную бинаризацию (Otsu по окну) — для качественного скан-копии это обычно выигрыш в читаемости, но здесь нам нужна не читаемость, а стабильная геометрия компонент: связность компоненты, её ширина и наклон. Адаптивный порог «съедает» тонкие штрихи над гласными у букв с высоким x-height и добавляет шум в просветлах, поэтому глобальный порог + несложная пост-фильтрация дают лучший результат именно для этой задачи.

3.2. Что считаем компонентой

python
labels, n = ndimage.label(mask, structure=np.ones((3, 3)))   # связность 8

Дальше по каждой компоненте считаем:

ang ≈ 35° bbox акут w=12 · h=9 · area=12 проходит все пороги bbox осколок буквы w=9 · h=3 · area=3 отброшен: мал и не вытянут bbox часть буквы w=6 · h=24 · area=16 отброшен: MAX_H = 16
Одна и та же сетка пикселей, три компоненты связности. Пунктир — bbox, рамка вокруг компоненты. У акута добавочно проведена главная ось компоненты: направление, вдоль которого она вытянута сильнее всего, — это и есть ang.
Простыми словами: компонента, bbox, наклон и что вообще считает эта часть

Здесь начинается собственно «компьютерное зрение» — и, важно, без нейросети. Работает обычный код на Python: несколько библиотек, арифметика и правила.

Бинаризация — первый шаг. Картинка из оттенков серого превращается в две краски: бумага становится белой, а любая точка темнее порога 170 — чёрной. Дальше цвет не важен, важна только форма: у буквы есть ширина и толщина, у знака ударения — своя, и вот эти различия в числах мы и будем измерять.

Компонента связности — это группа чёрных пикселей, соединённых друг с другом. Ключевая деталь в параметре structure=np.ones((3, 3)): матрица 3×3 из единиц означает связность по 8 направлениям — пиксели считаются соседями и по стороне, и по углу. Это важно для тонких наклонных штрихов: при связности только по сторонам наш акут распался бы на несколько отдельных кусочков, и мы бы его не нашли вообще.

Дальше по каждой группе считаем четыре безобидные вещи:

  • h и w — высота и ширина. Это размеры bbox: прямоугольной рамки вокруг компоненты. Самая простая метрика, и, как окажется, самая решающая.
  • area — сколько пикселей заняла компонента. Отличается от h·w тем, что считает только закрашенное, без пустых углов.
  • ang — наклон. Находится так: программа мысленно облекает пиксели в эллипс и берёт его длинную ось. У акута эллипс вытянут вдоль штриха, вот это направление и есть ang. Формула в коде — стандартная: atan2 переводит пару чисел в угол, а числа получаются из ковариационной матрицы. Если совсем просто: ковариация показывает, как координаты пикселей связаны друг с другом, а главная ось этой «облако точек» говорит, куда оно вытянуто.
  • elong — насколько сильно вытянуто. Считается как отношение двух собственных значений той же матрицы: чем больше отношение, тем сильнее вытянутость. Акут вытянут в 2,2–3,0 раза, квадратный кусок — примерно в 1,0.

Всё, что нужно для решения задачи «найти наклонный штрих над гласной», укладывается в эти четыре числа. Ни обучения, ни весов, ни нейросети — просто геометрия плюс границы, подобранные на настоящих знаках.

3.3. Какие параметры у настоящего акута

Собрали гистограммы по ~2800 найденным знакам на 10 страницах. У настоящего знака ударения:

ПараметрЗначение
высота h6–10 px (обычно 7)
ширина w8–13 px
площадь area34–59 px
наклон |ang|30–40° (мода 36°)
elong2.2–3.0
положениенад гласной, в верхней трети строки

Пороги в коде:

python
MIN_H, MAX_H = 4, 16
MIN_W, MAX_W = 4, 18
MIN_AREA, MAX_AREA = 18, 260
MIN_ELONG = 1.9
ANG_LO, ANG_HI = 22.0, 72.0
BODY_POS, BOTTOM_POS = 0.75, 0.80

MAX_H = 16 — ключевое число: высота строчной буквы 22–24 px, поэтому любая компонента выше 16 px — это не знак, а часть буквы. Это отсекает основной мусор «на уровне идеи», без всякой морфологии.

Простыми словами: откуда взялись эти границы и почему порогов так много

Границы не выдуманы и не взяты из учебника — мы их измерили сами. На десяти страницах книги нашлось около 2800 знаков ударения, и для каждого мы записали все четыре метрики. Потом посмотрели, в каких диапазонах эти числа живут, и получили таблицу выше: высота 6–10 пикселей, ширина 8–13, площадь 34–59, наклон 30–40 градусов.

Дальше границы раздвинули с запасом, чтобы попадали не только «идеальные» знаки, но и пограничные: MIN_H, MAX_H = 4, 16 и так далее. Это обычная практика — оставить зазор, иначе каждая неидеальная оттиска будет отброшена.

А самое красивое здесь — MAX_H = 16. Строчная буква в этом шрифте имеет высоту 22–24 пикселя. Знак ударения — 6–10. Между ними пропасть в два с лишним раза, и она ничем не заполнена. Значит, одно сравнение высоты отсекает весь основной мусор: любой кусок буквы выше 16 пикселей автоматически не является знаком, и на него даже не надо смотреть.

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

3.4. Три класса ложных срабатываний и как они лечатся

Ложные срабатывания были не случайными, а трех типов, и каждый лечится по-разному.

Тип 1: осколки букв. Диагонали Ж, Е, Г, И иногда отрываются от основного штриха при сканировании и выглядят как отдельные компоненты 4–7 px. Ключевая идея лечения — геометрия, а не морфология: если bbox маленькой компоненты пересекается с bbox большой (буквы ≥15 px) компоненты по площади хотя бы на 35 %, это осколок, а не знак.

python
ox = min(x0 + w, lx1) - max(x0, lx0)
oy = min(y0 + h, ly1) - max(y0, ly0)
if ox > 0 and oy > 0 and ox * oy >= 0.35 * area:
    reject("inside_letter")

После этого число «знаков» упало с 2848 до ~2600 на тех же 35 колонках.

Тип 2: кавычки и апострофы. Узкие крутые штрихи (area ≈ 23, w = 7, |ang| ≈ 66°) — это двойные кавычки " в тексте передней части книги. Отсекаются связкой правил: крутой наклон и маленькая площадь.

python
not (abs(ang) > 48.0 and area < 30)

Тип 3: точки «ё». Самая интересная история, разбор ниже.

3.5. Главное открытие: точку «ё» нельзя отличить от акута

Первая версия детектора считала, что у знака ударения есть характерный наклон, и отбрасывала всё с наклоном близким к 90° (то есть почти вертикальное). Наивное «наклон вправо = акут» работало на 90 % мест и ломало всё остальное.

Разбор одного конкретного случая. Строка «Сён-Бернар»:

ascii
584 .....#####............      <- компонента 43–47
585 .....#####............
586 .....#######..........
587 #.....######..........
588 #......#######........
589 #........#####........
590 #..........##.........

Слева, в самой строке — не буква, а точка «ё» (диарезис) у слова «Сон». Мы её находили как знак ударения и превращали слово в «Се́н-Бернар».

Слово при этом то же самое: «Сен-Бернар» существует, и «Се́н-Бернар» — тот же самый Сен-Бернар. Ошибка не в слове, а в расстановке ударений. По Словарю географических названий зарубежных стран правильная форма — Сен-Берна́р, с ударением на втором слоге, так что знак встал не на ту букву, а получилась запись, которой в словаре ударений быть не может: у нас ключ включает позицию знака, то есть сенберна+р и се+нбернар — это два разных ключа для одного слова.

Здесь важно вот что: никакой проверки по смыслу такую ошибку не поймает. Слово-то правильное, ошибочна только разметка, а на слово любая смысловая проверка смотрит только как на набор букв. Для словаря, который про ударения, это уже поломанный словарный ключ — и в базе такой лежать не должен.

Чтобы понять, что это за штрих, мы увеличили фрагмент в 18 раз (ближайший соседний масштаб) и посмотрели глазами:

о акут Акут диагональ вверх-вправо, толщина 3–4 px, |ang| 30–40°
ё точка «ё» Точка «ё» капля, толщина 5–6 px, наклон бывает другим
При малых размерах шрифта это буквально одна и та же компонента. Пунктиром — вторая точка диарезиса, которая сливается с засечкой соседней буквы и до списка компонент не доходит.

Дальше проверили ту же гипотезу на полной выборке и нашли два факта, которые сломали наивную модель:

  1. У настоящих акутов наклон бывает разным. В строке «Мохе́нджо-Да́ро» один и тот же шрифт даёт акут над «е» с наклоном +39° и акут над «а» с наклоном −36°. Печать наклоняет знаки непоследовательно (вероятно, это ручная разметка/перенос в издательстве). Значит, знак наклона нельзя использовать как признак акута — ни «левый», ни «правый».
  2. Диарезис «ё» состоит из двух точек, но видно обычно только одну. Вторая точка «съедает» верхнюю засечку соседней буквы и сливается с ней — то есть объединяется с компонентой буквы, и второй точки в списке компонент просто нет. Меняем мысль: раз «точка» осталась одна, надо смотреть не на форму, а на парность — искать рядом вторую компоненту того же размера в 8–17 px.
python
def has_dot_sibling(cand, dots):
    cx = cand["x"] + cand["w"] / 2
    cy = cand["y"] + cand["h"] / 2
    for (dx0, dy0, dx1, dy1) in dots:
        dcx, dcy = (dx0 + dx1) / 2, (dy0 + dy1) / 2
        gap = abs(dcx - cx) - cand["w"] / 2 - (dx1 - dx0) / 2
        if 8 <= gap <= 17 and abs(dcy - cy) <= 4:
            return True
    return False

Итог: полностью различить «точка «ё»» и «акут» по геометрии нельзя (при малых размерах шрифта это буквально одна и та же компонента). Поэтому такие случаи — не задача CV, а задача следующего этапа: их нужно квантовать в отдельную очередь на проверку (см. этап 5). Это честнее, чем встраивать в детектор эвристику, которая закроет 90 % случаев и тихо испортит оставшиеся 10 %.

Простыми словами: почему в этом месте не сработала проверка «такого слова нет»

Случай со «Сен-Бернаром» любопытен именно тем, что слово оказалось правильным. Никакой ошибки распознавания в нём не было: «Сон-Бернар» прочитано верно, буквы верные. Ошибка была в одном символе над словом — и этого достаточно, чтобы запись в словаре ударений стала негодной.

Отсюда важный вывод про словари ударений как таковые. Проверка «существует ли такое слово» здесь бесполезна: Се́н-Бернар и Сен-Бернар — одно и то же слово, и словарь их прекрасно различает как две разные статьи, потому что в нём ударение и есть часть записи. Проверять нужно не слово, а позицию знака: есть ли в источнике знак и стоит ли он там, где стоит.

Это и есть тот тип ошибок, который невозможно поймать сравнением с внешним словарём (см. этап 8) — там сверяют слова, а ломается разметка. И это ещё один довод за то, чтобы оставить такие случаи нейросети: человек видит картинку и сравнивает с печатью, а код может сравнить только свои же метрики.

Правильную форму можно посмотреть в любом авторитетном источнике: Словарь географических названий зарубежных стран даёт Сен-Берна́р, с ударением на втором слоге. То есть наш детектор ошибался дважды: и поставил знак не на ту букву, и заодно съел ё, превратив «Сён» в «Сен».

3.6. Привязка знака к строке

Знаки и строки не лежат в одном координатном пространстве: строка tesseract может «раздуться» до высоты 50 px (склейка двух печатных строк), а знак над гласной стоит на 2–4 px выше её верха. Поэтому привязка делается по вертикали с допуском, а выбирается ближайшая строка:

python
cands = [i for i, b in enumerate(boxes) if b.y0 - 10 <= mcy <= b.y1]
best = min(cands, key=lambda i: abs(mcy - boxes[i].y0))

Первая версия использовала окно [y0-2, y1+2], и на строке «ГИЛЬБЕРТ» знак уезжал на предыдущую строку «Ги́лфорд». Смягчение окна до [y0-10, y1] с выбором по минимальному abs(cy - y0) дало 2848/2848 знаков привязано, 0 потерянных.

3.7. Привязка знака к букве внутри слова

Внутри слова знак распределяется по буквам пропорционально делению (это важно: в издательском наборе ширина букв разная, но близкая к равномерной для кириллицы):

python
p = (mark_cx - tok.x) / tok.w
idx = min(max(int(p * len(tok.text)), 0), len(tok.text) - 1)

Почему не по центру первой буквы и не «ближайшая гласная»: для многосложных слов вроде «Гине́йфа» или «А́ле́кси-Ме́схисхви́ли» реальные позиции ударений — это 3–5, и любое упрощение даёт систематический сдвиг.

Здесь же применяются правила починки букв (см. этап 4).

Простыми словами: как знак ударения «знает», к какой букве он относится

Знак найден, но он пока просто кусочек бумаги с координатами — четырьмя числами: где он лежит по горизонтали и по вертикали. Осталось понять, над какой буквой он стоит. Для этого есть готовая геометрия из этапа 2: у каждого слова распознаватель сообщил рамку.

Формула в коде делает ровно одну вещь — переводит позицию знака в позицию буквы. Знак находится на доле p от левого края слова, а буквы в слове условно равны по ширине, поэтому та же доля p в слове из n букв указывает на букву с номером p·n. Дробь округляется вниз, а крайние случаи подрезаются, чтобы индекс не выехал за границы слова.

Наивные альтернативы здесь плохи по двум причинам. «Поставить знак на первую букву» не работает, потому что в длинных словах знак может стоять на третьей-пятой букве. «Поставить на ближайшую гласную» — тоже: в словах вроде «А́ле́кси-Ме́схисхви́ли» ближайшая гласная к знаку может быть не той, что в печати, и ошибка будет систематической, то есть повторящейся на тысячах слов подряд. Пропорциональное деление — единственный способ, который не сдвигает ударение целиком.

Важная оговорка про равномерность букв: в наборе ширина букв всё-таки разная — «м» шире, «і» уже. Мы сознательно пошли на это приближение, потому что ошибка от него (доли пикселя) намного меньше ошибки от «угадай позицию». Подбирать точное соответствие буквенных ширин имеет смысл, только если точность станет упираться.

3.8. Итог по этапу

Для каждой колонки получаем accents.json — список знаков с координатами и метриками. На всей книге: 742 колонки, 57 741 знак ударения, 0 потерянных при привязке к строкам, 58 % признаков отбраковано фильтрами.

Этап 4. Починка букв: почему порядок «детект → починка» обязателен

После того как знаки найдены, OCR-ошибки чинятся точечно:

Символ tesseractЧто означает при наличии знака в той же ячейкеКак чинится
й / Йи́ / И́заменяем букву, добавляем U+0301
&а́заменяем букву, добавляем U+0301
ё / Ёе́ / Е́заменяем букву, добавляем U+0301
б / Бо́ / О́заменяем букву, добавляем U+0301
CEHI'OPСЕНГО́Рcaps-латиница → кириллица, апостроф → U+0301

Статистика по всей книге: й→и́ 7 939, е́→ё 9 472, о́→б 6 287, а́→& 433.

Почему только при наличии знака? Без этого условия починка превращается в угадывание: й в слове может быть настоящей й (Гайд, Йошикава), ё — настоящей ё (Гёте), б — настоящей б. Наличие/отсутствие знака в этой ячейке — единственная доступная нам улика.

Отдельная тонкость: caps-кириллица, прочитанная латиницей, восстанавливается только для полностью прописных токенов. Мы пробовали и для смешанного регистра, но в передней части книги латинские имена (Aalto, Bing, Ape) набраны смешанным регистром, и транслитерация превращала Aalto → Аалто. Для прописных токенов риска нет: в словаре имён латиница в прописном виде не встречается, а настоящие КАПС-имена всегда кириллические.

Простыми словами: что такое U+0301, зачем знак ударения и почему его приходится ставить вручную

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

«Аге́нко» — кириллица плюс комбинирующий акут. Буква е — это U+0435, акут лежит отдельным символом U+0301 поверх неё. Именно так записан весь словарь проекта.

«Агéнко» — латинская é. Это один готовый символ U+00E9, акцент уже вписан в саму букву, кириллицы в слове нет вообще. Пиксель тот же, но по коду это другая строка и другой алфавит.

Практическое следствие выглядит так: движок ищет слово в словаре ударений по нормализованному ключу, и Агéнко с латинской é под этот ключ не подойдёт — база хранит агенко кириллицей. Такое слово будет произнесено как «агЕнко» наугад, а не по словарю. Визуально отличить их в тексте нельзя, поэтому такие штуки приходится ловить на этапе данных, а не на этапе озвучки. По той же причине весь разбор строк, где идут сравнения, считается не по готовой строке, а по позиции ударения: напишем мы форму с плюсом перед первой буквой или после третьей — ударная позиция от этого не меняется, а строки разные.

Буква «и» — это U+0435, а акцент сверху — U+0301, комбинирующий знак. Вместе они дают визуально «и́», но в памяти компьютера лежат как два символа, а не один. Именно поэтому в словаре везде пишут U+0301: это единственный способ записать ударение так, чтобы оно пережило копирование, поиск и преобразование текста.

Раз эта починка идёт «вручную», стоит объяснить, что она делает и почему безопасна. Правило одно и очень простое: если детектор знаков нашёл акцент в той же ячейке, где стоит сомнительный символ, значит, этот символ — искажённая буква, и её надо заменить. Например, распознаватель написал й, а детектор нашёл рядом акцент. Но печатный облик й — это как раз «и с краткой сверху», значит, в оригинале там была именно и́: настоящее «и» с ударением.

Здесь важна честная оговорка про ё и б. Диарезис у «ё» в печати выглядит ровно так же, как акцент, а «о́» при плохом скане сливается в «б». Мы чиним и эти случаи — но, как показано в разделе 3.5, это чисто на эвристике, и такие случаи потом специально отправляются на перепроверку.

Про капитальные имена — отдельная история. Когда в потоке латиницы попадается полностью прописный токен вроде CEHI'OP, мы конвертируем латиницу в кириллицу и превращаем апостроф в акцент. Но только для полностью прописных: в передней части книги есть настоящие латинские имена, набранные со строчных, и конвертация превратила бы Aalto в несуществующее «Аалто». Для прописных токенов риска нет: в словаре имён латинских имён в верхнем регистре не бывает по построению.

Этап 5. Авто-фильтр: что нести на «дорогую» проверку

Очевидная идея — отдать всё подряд LLM-верификации. Практика показала, что это и дорого, и избыточно: 93 % токенов заведомо верны, а «LLM проверяет всё» — это и деньги, и риск внести ошибку в 93 % правильных мест (мы это наблюдали в реальности, см. этап 6).

Поэтому мы построили детерминированный фильтр с приоритетами, и он на удивление хорошо работает: recall по строкам 84–100 % (измерено против полной LLM-проверки на двух колонках), при 38 % отмеченных токенов.

121 170 токенов на входе фильтра 62 % 38 % детерминированный фильтр — NN не нужен LLM-верификация 231 батч · ~70 ч 53 % токенов · принято 59 % 93 % токенов заведомо верны — риск внести в них ошибку выше, чем цена проверки
Очевидная идея «скормить всё модели» отбрасывается: 93 % токенов заведомо верны, а риск внести в них ошибку выше, чем цена проверки. Recall фильтра по строкам — 84–100 %.

Причины и их логика:

ПричинаЧто означает
yo_markна ё/Ё встал знак: это либо е́, либо точки «ё» — неразрешимо на CV, нужен взгляд
mark_off_vowelзнак упал на согласную: почти всегда это порча о́→б (иногда наш промах по ячейке)
head_no_markв строке статьи не оказалось ни одного знака — подозрение на потерянное ударение
line_no_markто же для строки без заглавной (продолжение статьи)
multi_mark2+ знаков на одном токене: в статьях вида «ГРАСИА́Н-И-МОРА́ЛЕС» это норма, но часто и ошибка
yod_leftй без знака (часто пропавший и́)
yo_no_markё без знака (часто пропавший е́)
latin_convтокен был переведён из латиницы, нужна проверка букв
weak_markзнак с подозрительной геометрией (крутой, мелкий)
amp_corrupt, junk_char, odd_fixмусор OCR
low_conftesseract сам неуверен (для имени это нормально)

Отдельно стоит сказать про low_conf: 41 % отмеченных токенов — это «tesseract неуверен», и правок там почти нет (буква уже исправлена, знак уже поставлен). Мы оставили их последними в очереди, потому что это самый дешёвый по качеству и самый дорогой по объёму класс.

Простыми словами: что такое recall, зачем нужен фильтр и почему 38 % — это удача, а не компромисс

Фильтр в этапе 5 — это список подозрительных мест. Каждый токен, у которого есть хоть один странный признак, получает метку-причину: yo_mark, low_conf и так далее. Всё с меткой отправляется на дорогую проверку, всё без метки считается верным.

Главная метрика качества такого фильтра — recall, полнота: сколько процентов строк с реальными ошибками он поймал. Считается против эталона — мы взяли две колонки, прогнали их целиком через дорогую проверку и посмотрели, какие строки с ошибками фильтр пропустил. Получилось 84–100 %: на двух колонках фильтр поймал от 84 % до 100 % строк с реальными ошибками.

Читать это надо аккуратно. 84 % — это не «84 % словаря исправно», это «из всех строк, где ошибка была, мы заметили 84 %». Метрика считается по строкам, а не по токенам, потому что важно не пропустить хотя бы одну ошибку в строке.

Почему вообще не отправить всё подряд? Потому что 93 % токенов заведомо верны, и тут возникает неожиданный эффект. Дорогая проверка стоит денег, но модель при этом ошибается в обе стороны — она и чинит, и портит. Если скормить ей 100 % токенов, то в 93 % верных мест появится шанс внести новую ошибку, а это путь, откуда ошибки не отследить: выглядит как «источник был прав, а мы испортили».

Отсюда же — про порядок в очереди. low_conf даёт 41 % отмеченных токенов, но правок внутри почти нет: буква уже исправлена этапом 4, знак уже поставлен. Это самый дорогой по объёму и самый бесполезный класс, поэтому он в конце очереди: если времени хватит не на всё, выигрыш будет максимальным с начала.

Этап 6. LLM-верификация по кропам: главный трюк — не доверять, а сверять

6.1. Как выглядит вход

Для NN-этапа нужны картинки конкретных строк. Строим их так, чтобы у модели не было физической возможности «додумать»:

  1. crops/{col}/line_{y}.png — кроп строки целиком: в первом проходе это правильное решение, модель должна видеть контекст, иначе она начинает угадывать по смыслу. Но для аудита конкретной правки кроп строки опасен — модель отвечает про соседнюю статью (см. 6.5), и там нужен кроп одного токена с полосой над знаком ударения.
  2. Склеиваем кропы в листы по 6 строк вертикально (это 100 токенов в 17 листах вместо 100 отдельных картинок — почти в 6 раз меньше вызовов инструментов).
  3. Увеличиваем ×2 с LANCZOS. Это важнее, чем кажется: на родном масштабе тонкий знак над «і» в слове «ЛІ́ММТЕД» не виден — мы сами ошиблись, приняв корректное слово за ошибку. После увеличения всё читается.

Промпт — короткий и жёсткий по «правилам игры»:

Не нормализуй: в источнике бывают двойные ударения («Се́н-Берна́р») и необычные формы — воспроизводи печать, а не «правильную» орфографию. Не додумывай: неуверен — «ok», не добавляй букв. Проверяй только перечисленные токены. yo_mark: на ё/Ё встал знак — либо в печати «е́», либо это точки «ё».

Формулировка «не нормализуй» появилась после первого же прогона, где модель массово «исправляла» печать по смыслу: превращала «СЁН-ЖЮ́СТ» в «СЁН-ЖЮ́ОСТ» (добавляла букву), «Сён-Лоре́на» — в «Сён-Лора́на» (по русской норме, а в печати так), «Се́н-Берна́р» — в «Сен-Берна́р» (убирала двойное ударение из печати). Это не «ошибки OCR», это порча источника.

Простыми словами: что такое кроп, лист, «инструмент» и галлюцинация

На этом этапе работает уже нейросеть — но не любая, а мультимодальная, то есть умеющая смотреть на картинки, а не только на текст. Такие модели умеют читать изображение и отвечать текстом. Мы даём ей не «слова из словаря», а фрагменты страницы — так проверка выглядит нагляднее и заодно не даёт модели рассуждать на отрыве от источника.

Три приёма, которые делают вход «честным»:

  • Кроп строки целиком, а не только спорного слова. Это ключевой момент. Слово без контекста модель читает как случайный набор букв и пытается угадать. Со строкой она видит соседей и может опереться на то, как слово выглядит в соседних строках — а это самый надёжный источник сведений в задаче.
  • Склейка в листы. Шесть строк склеиваются вертикально в одну картинку. Проверяемых слов в батче 100, картинок получается 15 вместо 100. Выигрыш двойной: и вызовов меньше, и токенов меньше, потому что на склейке не страдает разрешение (строки короткие).
  • Увеличение ×2 с LANCZOS. Алгоритм пересэмплинга, который лучше большинства прочих сохраняет тонкие линии при увеличении. Увеличение нужно, потому что на исходном масштабе тонкий акцент над «і» в слове «ЛІ́ММТЕД» просто не виден, и мы сами приняли корректное слово за ошибку, пока не увеличили.

Что такое «инструмент» в этой статье. Модель получает картинку не напрямую, а через инструмент: скрипт открывает PNG-файл и передаёт байты. Отсюда следует целый класс граблей, о которых стоит знать заранее: часть файлов при чтении инструментом возвращала «чужие» байты — другую картинку целиком, без всякой ошибки. Со стороны это выглядит как «картинка не такая», и легко списать на «скан плохой», хотя сканы в порядке.

Галлюцинация — когда модель уверенно сообщает то, чего в источнике нет: дорисовывает букву, придумывает слово целиком, «исправляет» печать по языковой норме. Здесь это не редкость, а норма: первый прогон дал 51 правку из 75 токенов, и заметная часть из них была именно галлюцинациями. С этим в статье разбираются отдельным фильтром (6.3).

6.2. Опечатка издания vs ошибка OCR: где проходит граница

Самая интересная развилка во всём пайплайне — это не техническая, а смысловая. Источник 2001 года содержит собственные опечатки, и их нельзя смешивать с нашими ошибками распознавания.

Конкретный случай, статья «БАРБАДОС», колонка p018_Rc2:

вывод
Барба́дос ре́дфьюжн се́рвис ле́мнтед        <- наш результат
Барба́дос ре́дфьюжн се́рвис ЛІ́ММТЕ[Д]    <- как набрано

Что здесь произошло по шагам:

  1. tesseract прочитал «ЛІММТЕД» как «лёмнтед» — две подряд идущие «М» он съел, а «І» принял за «ё» (знак акцента превратился в «две точки»);
  2. наш YO_FIX честно превратил «ё» в «е́» — получилось «ле́мнтед», слово, которого нет;
  3. LLM-верификация увидела в печати «І́» и заменила гласный: «ли́мнтед» — буквы совпали с печатью, но в печати две «М»;
  4. правильное слово — «ли́митед» (транскрипция Limited из «Barbados Red Fusion Services Limited»), а в источнике — опечатка набора: «ММ» вместо «МИ».

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

Ошибку OCR чинить нужно всегда. Опечатку издания чинить нужно только тогда, когда правильное слово подтверждается источником.

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

Практическое правило, которое мы закрепили в промпте:

Не нормализуй: печать источника — норма, в т.ч. опечатки издания. НО если слово набрано с очевидной опечаткой в буквах, а рядом в той же строке/статье встречается правильное написание — восстанови правильное.

Почему не «исправлять всё по словарю»: в этом словаре полно форм, которые современный словарь считает устаревшими или ошибочными, но для 2001 года они были нормой («с+ена», «абак+ан», «магад+ан» в базе accents_index — как раз такие). Автоматическая «коррекция по современной норме» превратила бы 34 тысячи форм источника в нашу редактуру. А вот точечная правка очевидной опечатки набора — это не редактура, а реставрация.

Отдельно про «лимитед» стоит заметить: сначала я (человек) назвал правку NN «правильной», сверив только знак над «І», не проверив сами буквы. Правильный вывод из этого — любую «подтверждённую» правку подтверждаем целиком, а не по одному признаку: NN-правка проходила через CV-фильтр, а этот фильтр проверяет позицию знака, а не правильность букв, поэтому проверка «лимитед» была отдельной ручной задачей.

6.3. Главный принцип: модель не источник истины, а второй детектор

Мы прошли через две стадии деградации качества:

Поэтому NN-правки проходят через детерминированный фильтр приёма, где каждая правка сверяется с CV-сигналом. Правила:

ПроверкаОтклоняем
правка ставит знак на согласнуюnot_vowel (галлюцинация: «Арм́анда», «Эскул́п»)
правка вводит букву ё, которой не былоhallucinated_yo (NN не умеет отличать точки «ё» от акута)
правка добавляет знак там, где CV ничего не нашлаadded_without_cv_mark
правка убирает знак там, где CV его нашлаremoved_with_cv_mark
правка переставляет знак на другую букву без CV-марка тамmoved_without_cv_mark
длина слова изменилась (NN переписал слово)принять, но пометить на ревью

Именно этот фильтр поймал ровно ту правку, которую мы независимо проверили глазами («АВЕРИНЦЕ́В» — неверно), и отклонил выдуманное «совёт». Итоговая статистика по полному проходу NN-верификации:

статистика
предложено правок: 12 251   (из 23 100 проверенных токенов)
применено:          7 253   (59 % от предложенных)
отклонено:          4 979   (4 183 removed_with_cv_mark, 388 added_without_cv_mark,
                            334 no_stress_in_fix, 41 not_vowel, 20 moved_without_cv_mark,
                            13 hallucinated_yo)
на ревью:            4 678   (906 review_rewrite, 42 review_shift,
                            3 647 added_without_cv_mark, 83 manual)
применено отклонено из них на ревью 7 253 — принято 4 979 — мусор, отсечён CV-сигналом 4 678 — на аудит полный проход: 231 содержательный батч
Дорогой слой предлагает, дешёвый слой проверяет, человек смотрит только на спорное.

По полному прогону модель предлагает правку для 53 % проверенных токенов, 41 % предложенного отсеивается автоматически, а 59 % применяется. Из принятых 4 678 помечены как требующие повторной проверки, но на аудит по печати (6.5) пошли только 1 037 — те, где модель меняла буквы или двигала знак; остальные 3 647 — случаи «добавлен только знак ударения». Это и есть правильная архитектура: дорогой слой предлагает, дешёвый слой проверяет, человек смотрит только на спорное.

Доли выше — итоговые, по всему содержательному объёму книги. На первых пятнадцати батчах они выглядели заметно скромнее (9 % правок): алфавитный указатель в конце книги у́же, а словарная часть в начале — чище.

Простыми словами: почему модель — это второй детектор, а не судья

Самый контринтуитивный момент всей работы: модель в этой схеме не решает, что правильно. Она предлагает, а решение принимает обычный код, сверяя её предложение с тем, что нашла старая, неслнеосяжная часть пайплайна.

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

Отсюда архитектура в одной фразе: дорогой слой предлагает, дешёвый слой проверяет, человек смотрит только на спорное. В наших цифрах модель предлагает правку для 53 % проверенных токенов, 41 % из предложенного фильтр отсекает автоматически, а на аудит по печати ушли только те, где модель меняла буквы или двигала знак, — 1 037 из 7 253 принятых. Остальные 3 647 — это случаи «добавлен только знак», где риск минимален.

Это и есть главный практический вывод работы. Когда модель предлагает правку, а детерминированный фильтр её проверяет, выигрывает не «качество модели», а сама конструкция: улучшения промпта дают единицы процентов, а автоматическая сверка — десятки.

6.4. Экономика

Значение
Токенов в словаре121 170
Отмечено фильтром46 402 (38 %)
Отсеяно как транскрипции в скобках3 201
Батчей по 100 токенов231 содержательных + 192 батча указателя
Проверено токенов23 100
Правок предложено12 251 (53 % проверенных)
Листов сгенерировано3 927 (17 на батч)
Чистое время генерации (оценка)~15 ч
Оценка полного прохода с подготовкой и паузами~70 ч (≈3 суток)

Разница в 5 раз — это не генерация, а всё, что вокруг неё: рендер и запись 3 927 листов на диск, сборка батчей и очередей, паузы между генерациями (лимиты, ретраи, «инструмент вернул чужие байты») и кропы с ×10, где модель считает в 25 раз больше пикселей. Три параллельных агента тут не помогают: узкое место не в concurrency, а в подготовке. 231 батч за ~70 ч — примерно один батч каждые 18 минут.

Мы решали, делать ли полный проход. Ответ — да: 41 % предложенных правок от LLM автоматически отсекаются, а принятые 59 % дают прирост точности, и «горячие» причины (yo_mark — неоднозначность е́/ё) без NN неразрешимы в принципе.

Сколько это стоит в деньгах, если считать через API

Отдельный вопрос, который почти всегда возникает: сколько бы это стоило, если бы мы не сидели на бесплатных лимитах. Считаем на текущих ценах, объём — 231 батч по 100 токенов, 17 листов в батче, то есть 3 927 картинок.

Сколько токенов съедает картинка, зависит от того, как провайдер её считает. Лист из шести строк — примерно 1560×600 пикселей:

Схема подсчётаТокенов на листНа весь проход
OpenAI (тайлы 512×512)5952,3 M
Google (тайлы 768×768)7743,0 M
Anthropic (по площади, w·h/750)1 2484,9 M

Добавим текст промпта (около 2 800 токенов на батч) и ответы с правками (около 350 на батч): на 231 батч это 0,65 млн входных и 81 тысяча выходных. Получается 3–5,6 млн входных токенов и 81 тысячу выходных на всю книгу.

Модель$/1M in$/1M out1 проходBatch API (−50 %)
Gemini 2.5 Flash-Lite0.100.40$0.40$0.20
GPT-4.1 nano0.100.40$0.33$0.17
Gemini 2.5 Flash0.302.50$1.31$0.65
Claude Haiku 4.51.005.00$5.96$2.98
GPT-4.12.008.00$6.63$3.31
Claude Sonnet 52.0010.00$11.91$5.96
Claude Opus 5.54.0020.00$23.82$11.91

Цены — на 30 сентября 2026. С учётом трёх прогонов промпта (v1, v2, финальный) реальный счёт выходит в пределах $1–70. При 7 253 принятых правках это доли цента за правку.

Два практических вывода, которые важнее самой цифры.

  • Увеличение ×2 → ×10 не дороже на OpenAI и Google. Их токенизаторы приводят картинку к сетке тайлов по меньшей стороне, а однородный зум сохраняет пропорции сторон — число тайлов то же. Лист 1560×600 и лист 7800×3000 стоят одинаково: 595 и 774 токена. У Anthropic биллинг по площади, поэтому там ×10 дороже ровно в 25 раз (1 248 → 31 200), и батчи с крупным увеличением надо гнать на OpenAI или Gemini.
  • Склейка шести строк в лист выгоднее, чем кажется. Не «в 6 раз меньше вызовов», а примерно в 28–32 раза меньше токенов: одиночная строка 1560×100 настолько вытянута, что после нормализации разворачивается в 16 тайлов (2 805 токенов) против 595 у шести строк вместе.

И главное, ради чего эта таблица и приводится: деньги тут не были узким местом. Вилка от 78 центов до 32 долларов за всю книгу — это стоимость пары чашек кофе. Настоящая цена работы — инженерное время на детектор, фильтры приёма и ручную вычитку спорного. Бесплатные лимиты тут ничего не экономят: разница между «бесплатно» и «$32» меньше, чем стоимость одного приёма пищи за двадцать пять часов работы.

6.5. Аудит рискованных правок: модель, которая не правит, а проверяет

Фильтр приёма помечает две группы правок как подозрительные, но всё же применяет их: review_rewrite (модель переписала слово) и review_shift (знак переставлен на другой слог). По вкусовой выборке среди них около 15 % — ошибки модели: Ба́нца → Ба́лиса, Канури → Калнтва́, Теро́н → Тетю́шн. Поймать их CV-фильтром нельзя: правка правдоподобна и не противоречит ни одному автоматическому сигналу.

Решение — второй проход с другой формулировкой задачи: не «исправь отмеченный токен», а «вот предложенная правка, подтверди или опровергни по печати». Кроп для этого нужен другой: по одному токену, ×3, с полосой 16 px над знаком ударения. Плюс страховка на приёме вердикта — исправление принимается, только если буквы совпадают с исходной правкой не менее чем на 70 %, иначе это ответ про соседнюю статью, а не про наш токен.

По 1 037 рискованным правкам:

аудит
подтверждено по печати   891
исправлено по печати      74
неразборчиво (оставлено)   56
промах мимо статьи         14

Точность рискованных правок — 92,1 % (891 верных из 967 прочитанных). Массовый класс «добавлен только знак ударения» (3 647 правок) отдельно не аудировался: риск там минимален, но и проверка стоила бы ещё одного прохода по книге.

Побочный эффект оказался непропорционально большой: на кропе строки доля применённых коррекций была около 30 %, на кропе токена — 100 % от вердиктов «no». Разница не в модели, а в том, что именно ей показывали.

Этап 7. Парсер статей → словарь

Формат вывода совпадает с уже принятым в проекте (как в dictionaries/propernouns.json.gz):

json
{"се́на": "се+на", "алан": "а+лан"}

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

Что отбрасываем при разборе колонок:

Итог по книге: 34 217 уникальных форм при ожидаемых ~35 000 словарных единиц — совпадение по порядку величины, значит, существенных потерь при разборе нет.

Простыми словами: что за формат со знаком «+» и зачем он такой

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

json
{"се́на": "се+на", "алан": "а+лан"}

Ключ нужен, чтобы найти слово: произнесённое или распознанное слово приходит без ударения, и по нему делаетсяlookup. Значение нужно, чтобы произнести: синтезатор получает форму с плюсом и ставит ударение в нужном месте. Тот же приём — записать ударение значком, а не отдельным символом поверх буквы, — мы применяли и в CV-детекторе: так буквы не нужно разбирать на элементы и не нужно знать про Unicode вообще.

Формат действительно тот же, что читает silero-stress, — но проверять это надо сравнением с рантаймом, а не с документацией. Наш парсер ставил + после ударной буквы (ааро+н), тогда как сервис читает + перед ударной гласной (аар+он); на глаз это одно и то же, и ошибка на символ стоила бы сразу во всех 34 тысячах форм. Конвертация одного словаря заняла минуты — и это дешевле любой микрооптимизации.

Что отбрасываем при разборе колонок — видно из списка в тексте, и логика простая: всё, что не является словарной статьёй об имени собственном. Транскрипции в скобках (это не имя, а чтение), колонтитулы (номера страниц), передняя часть книги (там объяснения, а не словарь), падежные пометы с дефисом (ключей таких в эталоне нет), слова с латиницей, которые не перевели, и отдельные огрызки — их легко отсечь по категориям Unicode P/S/N, то есть пунктуация, символы и числа.

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

Этап 8. Валидация: почему кросс-сверка со словарём имён врёт

Самая полезная находка этого этапа — отрицательный результат.

Мы сверили свои формы с accents_index.sqlite (names, 119 259 форм) и получили: 48 совпадений, 11 432 конфликта, 20 761 форм нет в БД. На первый взгляд — катастрофа. Но при разборе конфликтов выяснилось, что чаще неправ источник сравнения:

ФормаАгеенко (печать)БД
абаканабака́набак+ан
абаваАба́ва+абава
магаданМагада́нмагад+ан

Это старая (дореформенная/доокончательная) норма ударения, зафиксированная в БД. По этой причине кросс-сверка в нашем пайплайне остаётся информационной: она показывает, где стоит посмотреть глазами, но не является эталоном.

Отдельно: сравнение форм надо делать по позиции ударения, а не по строке — и сравнивать их, предварительно нормализовав соглашение. Символ + не занимает позицию, поэтому «ааро+н» и «аар+он» — это одно и то же ударение, записанное двумя соглашениями, а вот «маа+стрихт» и «ма+астрихт» — уже разные ударения. Наивное сравнение строк даёт ложные конфликты и занижает метрику в разы.

Простыми словами: что такое «отрицательный результат» и почему сверка с чужой базой врёт

Этап 8 — проверка. Мы сверили свои 34 тысячи форм с большой чужой базой accents_index.sqlite, где лежит 119 тысяч именных форм, и получили жуткие числа: 48 совпадений, 11 432 конфликта, 20 761 формы, которых в базе вообще нет. На первый взгляд — провал: значит, мы всё распознали неправильно.

Но полезнее оказался отрицательный результат: при разборе конфликтов выяснилось, что чаще неправ источник сравнения. «Абакан» в печати — «абака́н», в базе — «абак+ан». «Магадан» в печати — «Магада́н», в базе — «магад+ан». Это не ошибка распознавания и не ошибка базы: это старая норма ударения, зафиксированная в базе, — та, какой имя произносилось до реформы. Спорить с этим бесполезно, у нас нет эталонного источника ударений, чтобы решить, кто из нас прав.

Отсюда решение: кросс-сверка в пайплайне остаётся информационной. Она не «проверяет» и не «валидирует» — она подсвечивает места, куда стоит посмотреть глазами. Это принципиально разные вещи: проверка требует эталона, а у нас его нет.

И тут же техническая ловушка, из-за которой метрика сначала выглядела катастрофически. Сравнивать формы надо по позиции ударения, а не построчно. Плюс в формате словаря не занимает позицию (это не буква), поэтому «ааро+н» и «аар+он» значат одно и то же ударение — плюс просто стоит в разных местах строки. А вот «маа+стрихт» и «ма+астрихт» — это уже разные ударения, и наивное сравнение строк найдёт там расхождение, занизив метрику в разы.

8.1. Сверка с общим лексиконом: из отрицательного результата в измерение

Сверка со слоем имён дала «провал», и мы честно назвали это отрицательным результатом. Но общий лексикон (lex, 3,19 млн словоформ общеязыковых слов) ведёт себя иначе: расхождений с ним почти нет, и он годится не только как индикатор, но и как эталон.

Сверка Ageenko с lex после вычисткиФорм
Совпали по позиции ударения15 989 из 15 991
Расходятся по позиции2 — ия и яя, формы из одного слога, где Ageenko честно ставит знак, а лексикон молчит
Позиция та же, но Ageenko помечает оба слога29 — составные названия: алекс+андрово-зав+одский против алекс+андрово-заводский. Это не конфликт, а более полная разметка
В lex отсутствуют18 226 — это и есть то самое покрытие имён, ради которого всё затевалось

И сверка сработала как источник правок, а не только как метрика. Она нашла два класса дефектов, которые на глаз не видны:

Оба класса дешёвые: это чтение уже посчитанных данных, а не новый проход по книге. Так что этап 8 стоит перечитать: врёт не кросс-сверка, а сверка с тем источником. Со слоем имён — расхождение это норма методическая, со слоем lex — лучший бесплатный тест из всех, что были в проекте: расхождение по существу — 2 формы из 15 991, это 0,01 %.

Оговорка про границы измерения. Независимым источником покрыто 48,3 % словаря — 16 513 форм, где lex или names подтверждают позицию ударения; ещё 4,0 % спорные. Оставшиеся 47,7 % — фамилии и имена, которых нет ни в одном слое: их проверяет только тот же конвейер, который их и породил. Поэтому честная формулировка точности — 98–99 % как оценка, а не как измерение. Измеренные цифры есть только для рискованных правок (92,1 %, аудит по печати) и для покрытой половины словаря (99,99 % по lex). Чтобы получить измеренную точность по всему словарю, нужен ручной аудит выборки из непроверяемой половины: 300 форм дают 95 % доверительный интервал около ±1,6 п.п.

Грабли, на которые стоит посмотреть отдельно

Что получилось и что дальше

МетрикаЗначение
Разворотов / колонок187 / 742
Токенов OCR121 170
Знаков ударения найдено57 741 (0 потеряно)
Форм в словаре34 217
Отмечено на NN-проверку38 % токенов (46 402)
Recall фильтра (по строкам)84–100 %
Точность рискованных NN-правок92,1 % — аудит 1 037 форм по печати, мусор отсекается по CV-сигналу
Накладной расход слоя Ageenko+0,05 мс на предложение (≈2 % времени акцентора)
Оценка точности словаря≈98,5 % — оценка, а не измерение; состав остатка и границы измерения разобраны ниже и в 8.1

Оценка точности: что измерено, а что оценено

Текущая оценка — около 98,5 % форм несут ударение, соответствующее печати. Важно не перепутать: это оценка, а не измерение, и она имеет конкретный срок годности — пока ручная проверка выборки не доведена до конца, цифра будет уточняться, причём вниз.

Что за оценкой стоит:

Класс формФормКак получена оценка
Подтверждено независимым источником16 513 (48,3 %)99,99 % — сверка позиции ударения с lex, расхождение 2 формы из 15 991
Спорные1 381 (4,0 %)расхождение со слоем names; в основном это норма издания 2001 года, а не ошибка
Проверять нечем16 323 (47,7 %)ручная выборка: 30 форм — 96,3 % точности, интервал широкий
Всего34 217≈98,5 %, диапазон 98–99 %

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

Куда движется цифра. Вниз. Каждый следующий батч ручного аудита может найти новое, а наш опыт первых 30 форм говорит, что находит: в первой же сотне форм нашлись потерянный знак и три испорченных ключа. Оценка 98,5 % — это верхняя граница того, что мы сейчас можем обосновать; реалистично после полной проверки выборки она опустится, скорее всего, в район 98 %.

Состояние измерения. Отбор случайной выборки из непроверяемой половины сделан: 300 форм, seed фиксирован, воспроизводимый. Проверено 30 — точность 96,3 % с 95 % доверительным интервалом 81,7–99,3 п.п., то есть на таком объёме число ещё ничего не доказывает и нужно только для калибровки. Доверительный интервал сужается до ±1,6 п.п. на 300 формах; после этого оценку можно заменить измерением в статье.

Проделано после первых 15 батчей: NN-проход закончен — 231 содержательный батч. Оставшиеся 192 батча пришлись на алфавитный указатель (стр. 14–187), где из 19 188 токенов заголовков без знака ударения — 0. То есть половину очереди можно было не оплачивать, если посчитать head_no_mark по батчам до начала генерации. Общеязычные слова вычищены (46 форм из ё-словарей и 5 обрывков текста), словарь подключён к сервису третьим слоем в accents_index.sqlite (таблица ageenko, 34 217 строк) с приоритетом ageenko → names → lex.

Что это дало на живых данных. Формы, где Ageenko расходится со слоем имён:

конфликт слоёв
Аарон     ageenko  аар+он    (ааро́н)     names  +аарон    (а́арон)
Абдель    ageenko  абд+ель   (абде́ль)    names  +абдель   (а́бдель)
Абрахам   ageenko  абрах+ам  (Абра́хам)   names  +абрахам  (А́брахам)
Августа   ageenko  +августа  (А́вгуста)   names  авг+уста   (авгу́ста)

Прогон таких фраз через /tts даёт age=2;age_out=2 в заголовке X-Stress-Audit: акцентор ошибался в двух формах, слой их исправил. На обычных словах (lex-fixed: 0, age_out: 0) именной словарь не срабатывает вообще — то есть наши 34 тысячи форм не задели ни одного обычного слова.

Плюс технические хвосты: юнит-тесты на парсер, кросс-сверка с names как отчёт о расхождениях (методическая норма) и кросс-сверка с lex как рабочий тест качества.

Главный вывод для практики. Если OCR не умеет читать нужный вам класс символов — не боритесь промптами, а стройте отдельный детерминированный слой, а затем проверяйте его выход независимым методом. И не путайте «модель проверила» с «модель права»: автоматическая сверка правок с исходным сигналом дала больше качества, чем любые улучшения промпта.

Где это ещё применимо

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

Что именно переносится, а что придётся заменить:

Наш этапКак переносится на другой источник
Рез на колонкимеханически: подстройка под вёрстку конкретного издания
OCR + TSVмеханически, если у tesseract есть язык издания
CV-детектор знакапереписать: метрики и пороги снимаются со своего шрифта
Починка буквпереписать: таблица правил специфична для кириллицы с акцентом
Авто-фильтрструктура переносится целиком, набор причин — под свой источник
LLM-по-кропампереносится целиком, включая фильтр приёма правок
Валидацияпереносится идея «сверка информационная, а не эталонная»

Самый дорогой по времени кусок здесь — CV-детектор, и это не совпадение. Границы в нём подобраны под конкретный шрифт: у другого издания другая высота строчной буквы, другой наклон, другая толщина штриха, и числа в таблице 3.3 придётся снимать заново — тот же приём с гистограммами по нескольким страницам отработает на любом материале.

Зато всё, что дороже отладки одной задачи, не требует переписывания. Авто-фильтр, LLM-по-кропам и фильтр приёма правок — это конвейер, а не привязка к ударениям. Их достаточно перенастроить на другие причины подозрительности.

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

Словарик

Термины в том смысле, в каком они используются в этой статье. Если читаете впервые — имеет смысл держать открытым.

OCR (Optical Character Recognition)
Распознавание текста на изображении. Программа получает картинку и возвращает текст. Здесь — tesseract, open-source-движок, самый распространённый в таких задачах.
TTS / STT
Text-to-speech — превращение текста в речь; speech-to-text — распознавание речи в текст. В проекте оба направления опираются на словарь ударений.
U+0301
Комбинирующий знак «острое ударение» в Unicode. Кладётся поверх буквы отдельным символом, а не встраивается в неё — поэтому «и» и ударение занимают в строке две позиции.
DjVu, dpi, разворот
Формат хранения отсканированных книг; «точек на дюйм», то есть мелкость скана; две печатные страницы рядом, как в открытой книге.
TSV
Tab-separated values — таблица с разделителями-табуляциями. Здесь tesseract отдаёт в ней по строке на каждое слово: текст, координаты, уровень вложенности и confidence.
Confidence
Насколько распознаватель уверен в своём прочтении, от 0 до 100. В статье используется как отдельный признак подозрения на ошибку.
Бинаризация
Превращение картинки из оттенков серого в две краски: всё темнее порога становится чёрным. Дальше важна только форма, а не цвет.
Компонента связности
Группа соседних тёмных пикселей, объединённая в одну фигуру. Находятся через ndimage.label; связность по 8 направлениям означает, что учитывается и касание по углу.
bbox (bounding box)
Прямоугольная рамка вокруг компоненты: четыре числа — координаты двух углов. Отсюда берутся ширина w и высота h.
Ковариационная матрица
Математическая сводка о том, как координаты пикселей связаны между собой. Её собственные значения дают «насколько вытянуто вдоль двух взаимно перпендикулярных направлений», а собственные векторы — сами направления.
atan2
Функция, переводящая пару чисел в угол в градусах. В формуле для ang она берёт угол главной оси компоненты; двойной аргумент принципиален — так угол корректно определяется во всех квадрантах.
elong
Отношение двух собственных значений ковариационной матрицы: во сколько раз компонента вытянута сильнее в длинную сторону, чем в короткую.
Морфология
Преобразования формы изображения: размытие, эрозия, наращивание. Мощный, но «размазывающий» инструмент — здесь сознательно не используется, чтобы не размывать тонкий штрих в акуте.
Кроп (crop)
Прямоугольный фрагмент исходной картинки. Здесь — отдельная строка или склеенный лист из шести строк.
LANCZOS
Алгоритм пересэмплинга (изменения размера изображения), который лучше большинства прочих сохраняет тонкие линии при увеличении.
Батч
Порция работы, отправляемая за один вызов: здесь 100 проверяемых токенов и 15 картинок-листов.
Recall (полнота)
Доля реально существующих проблем, которые система поймала. Здесь — «из строк с ошибками фильтр отметил столько-то процентов». Обратная метрика — precision, сколько из отмеченных действительно были проблемами.
Галлюцинация
Случай, когда модель уверенно сообщает то, чего в источнике нет: дорисовывает букву, придумывает слово, «исправляет» текст по норме вместо воспроизведения печати.
Batch API
Режим обращения к моделям, где запросы накапливаются и выполняются пачкой с задержкой. Обычно вдвое дешевле обычного режима — для офлайн-задачи вроде нашей это естественный выбор.
Детерминированный фильтр
Набор жёстких правил с предсказуемым результатом: одна и та же картинка всегда даёт один и тот же вердикт. Противоположность модели, у которой ответ зависит от случайности.

Вёрстка: CSS и встроенная SVG-графика, внешних ресурсов нет.

Числа актуальны по итогам полного прохода: 231 содержательный батч NN-верификации, аудит рискованных правок по печати, словарь вычищен и подключён к сервису.

© 2026 Merlin.
Crafting Dreams & Magic