OCR якутского языка: детектор строк и распознаватель
Два весовых файла и рецепт, по которому они получены. На газетной вёрстке CER 0.22%, WER 2.05%; специфические якутские буквы ҕ ҥ ө һ ү читаются наравне с остальными (пропуск 0.3%).
Готовые движки на этом языке не работают структурно, а не «плохо»:
| движок | CER | недобор ҕҥөһү |
|---|---|---|
| ABBYY FineReader 10 (оцифровка соседнего архива) | — | 100% (искажено 31.4% слов) |
| tesseract-rus | 9.76% | 100% |
| tesseract-kaz | 3.52% | 26.2% |
| эта модель | 0.22% | 0.3% |
Ноль правильно прочитанных спец-букв на 460 тысячах букв — это не настройка порогов, а отсутствие символов в алфавите модели. Они составляют около 7% всех букв и встречаются в 31% слов, так что треть слов повреждается.
Состав
| файл | что это | параметров |
|---|---|---|
onnx/detector.onnx |
детектор строк, компактный DB | 3.25 M |
onnx/recognizer.onnx |
распознаватель строки, CRNN + CTC | 10.08 M |
pytorch/*.pt |
те же веса для дообучения | |
src/ |
код: инференс, обучение, экспорт — плоской раскладкой, чтобы импорты работали как есть |
Как пользоваться
import json, numpy as np, onnxruntime as ort
from PIL import Image
meta = json.load(open("onnx/recognizer.json"))
charset, H = meta["charset"], meta["height"] # 123 символа, высота 48
sess = ort.InferenceSession("onnx/recognizer.onnx", providers=["CPUExecutionProvider"])
im = Image.open("line.png").convert("L")
# поле вокруг строки: сегментатор режет вплотную, а край кропа модель
# принимает за знак препинания
p = int(im.height * 0.18)
bg = int(np.median(np.asarray(im)[:, -3:]))
canvas = Image.new("L", (im.width + 2*p, im.height + 2*p), bg)
canvas.paste(im, (p, p)); im = canvas
im = im.resize((max(8, int(im.width * H / im.height)), H), Image.BILINEAR)
x = (1.0 - np.asarray(im, np.float32) / 255.0)[None, None]
logits = sess.run(None, {"image": x})[0][0]
ids, out, prev = logits.argmax(-1), [], -1
for k in ids: # схлопывание CTC, 0 = blank
if k != prev and k != 0:
out.append(charset[k - 1])
prev = k
print("".join(out))
Детектор принимает целую страницу (1 - gray/255, стороны кратны 32, длинная
сторона до 2000 px) и отдаёт карту вероятностей в половинном разрешении.
Разбор карты в рамки строк и порядок чтения — в src/detect.py.
Рецепт
1. Данные
- Синтетика — 500 тыс. строк,
lab-ii/sakha-ocr-synth. Заголовки, экранный рендер, «шакалирование», аугментация начертания. - Реальные строки — 258 тыс. строк газетной периодики с точной разметкой,
извлечённых из born-digital PDF: текстовый слой даёт эталон бесплатно.
Сами данные не публикуются (авторские права на газетный архив), но скрипт
извлечения лежит в
src/. - Страницы для детектора — 3220 полос из 274 выпусков; рамки строк взяты из того же текстового слоя. 9 выпусков, из которых нарезана оценочная выборка, отложены целиком.
2. Распознаватель
python src/train_rec.py --arch crnn \
--data data_v6,real_train \
--fonts fonts_manifest.json \
--steps 9000 --bs 96 --workers 24 --lr 3e-4 \
--out runs/GEN_v8
CRNN: свёрточный ствол, двунаправленный LSTM, CTC. Высота входа 48 px, ширина произвольная; батчи собираются по близкой ширине. OneCycleLR, ~26 минут на одной H200.
Две вещи, без которых не работает:
margin_jitter— строка обрезается до рамки чернил и получает случайное поле 0…0.42 её высоты, на 75% примеров. Без этого модель зависит от того, насколько плотно обрезан кроп: на тех же изображениях CER менялся с 10.2% до 2.9% от одного добавления поля, потому что край кропа принимался за точку.- Разбиение батчей по ширине — иначе короткие строки добиваются нулями, CTC читает добивку и дописывает символы.
3. Детектор строк
DET_SCALE=0.6,2.2 DET_AUG=0.70,0.18,0.12 \
python src/train_det.py \
--data det_train --val det_val \
--out runs/DET_v4 --steps 14000 --bs 16 --size 640 --lr 2e-3
Компактный DB (differentiable binarization) с нуля: предобученных весов взять неоткуда. Карта учится по сжатым рамкам — между строками и колонками остаётся зазор, и обученный порог разворачивает пятно обратно в полную строку. Шаг выхода 2, а не 4 как в оригинале: в газете строка занимает 10–14 px, при шаге 4 сжатая рамка вырождается в один пиксель.
Оценка идёт по ходу обучения на отложенных полосах, лучший чекпоинт
сохраняется по F1 — качество не растёт монотонно, и last.pt затирает лучший
результат.
4. Постобработка словарём
Пакет sakhaspell даёт лексикон и биграммную модель — языковой приор, которого
у CTC нет. Подключённый в лоб, как исправитель текста, он вредит: на 5400
реальных строках CER 0.241% → 0.894%, испорчено 838 строк против 44
улучшенных. Он не видит изображение и правит имена, русские вставки и обрывки
слов на переносах.
Поэтому словарь только предлагает кандидатов, а решает картинка: для каждой
замены считается CTC log-prob кандидата, и замена проходит, если выигрыш
языковой модели перевешивает потерю по изображению. При весе 1.0 — 36
улучшенных строк против одной ухудшенной на 2100, минус 8% ошибок там, где
распознаватель слаб. См. src/lm_rescore.py.
Метрики
Распознаватель, 5400 отложенных строк газеты с точным эталоном:
| CER | WER | пропуск ҕҥөһү | |
|---|---|---|---|
| эта модель | 0.22% | 2.05% | 0.3% |
| она же, только на синтетике | 0.95% | — | 1.7% |
| CRNN на синтетике, 521 шрифт | 1.26% | — | 2.2% |
| SVTR на синтетике | 2.31% | — | 2.4% |
Синтетические отложенные выборки: книга 0.12%, газета 3.79%, незнакомые гарнитуры 0.89% и 0.47%.
Детектор, 24 отложенные полосы (10 тыс. строк эталона, IoU 0.5):
| полнота | точность | порядок чтения | |
|---|---|---|---|
| морфологическая сегментация | 62.9% | 75.1% | 89.8% |
| детектор | 93.5% | 94.9% | 96.9% |
Сквозной результат на книжной странице (скриншот, не газета): CER 0.4%.
Чего ждать не стоит
- Рукописи не поддерживаются вовсе.
- Крупный книжный кегль (строка выше ~35 px) — слабое место: модель
училась на строках 6–22 px, и рамка садится на базовую линию, срезая
выносные элементы букв. Лечится расширением рамки по вертикали на 0.3
высоты строки, это уже сделано в
src/detect.py. - Советские книжные гарнитуры (Литературная, Школьная, Академическая, Журнальная) в обучении не представлены — их нет в открытом доступе, а именно ими набраны якутские книги 1940–1990-х.
- Газета 1940 года: колонки вплотную, межколоночные линейки прокрашены — разбор страницы там не работает.
Лицензия
CC BY 4.0. Веса получены в том числе на газетном архиве, который сам не
публикуется; синтетическая часть данных открыта: lab-ii/sakha-ocr-synth.