Atlorium

Сто страниц сканов и один оператор: когда распознавание текста дешевле, чем перепечатать

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

10 минут чтения бэкофис · документооборот · продакт-менеджер · разработчик

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

Восемь месяцев назад в накладной вместо ИНН 7707083893 в базу уехало 7707083839. Две последние цифры поменялись местами — обычная опечатка усталого человека на семидесятом документе за день. Платёж ушёл не туда, вернулся через одиннадцать дней, контрагент успел выставить неустойку. Разбирались две недели.

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

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

Где именно течёт

Разговор про оцифровку почти всегда начинается с фразы «нам бы сканы автоматически загонять в базу». Под ней прячутся три совершенно разные боли, и лечатся они по-разному.

Реквизиты набивают руками, и в них опечатки

Что происходит
ИНН, номер счёта, номер накладной, сумма. Пятнадцать-двадцать цифр подряд, которые человек переносит с картинки на клавиатуру, глядя попеременно в два места.
Во что обходится
Опечатка в расчётном счёте — это платёж не туда и от нескольких дней до пары недель на возврат. Опечатка в ИНН — непрошедшая сверка и ручной разбор в конце квартала.
Чем закрывается
Распознавание отдаёт текст страницы за секунду, и оператору остаётся сравнить две строки, а не набрать одну. Сравнивать глазами человек умеет гораздо лучше, чем набирать.

Приёмка документов упирается в одного человека

Что происходит
Сто двадцать сканов в день, один оператор, шесть часов чистого времени. Заболел — очередь копится, вышел — разгребает три дня.
Во что обходится
Полдня простоя в закрытии месяца и авральные переработки в конце квартала. Плюс невозможность вырасти по объёму без найма ещё одного человека.
Чем закрывается
Машина берёт на себя набор, человек — контроль. Пропускная способность одного оператора вырастает в разы, и потолок сдвигается без найма.

Данные в базе есть, но искать по ним нельзя

Что происходит
Архив из тридцати тысяч JPEG. Найти договор с конкретным контрагентом можно только по имени файла, а называли их как придётся.
Во что обходится
Юрист тратит полтора часа на поиск одного документа. На запрос от налоговой архив поднимают вручную неделю.
Чем закрывается
Распознанный текст ложится рядом с картинкой в поисковый индекс. Дальше архив ищется обычным поиском по подстроке.

Сколько это стоит в часах и когда окупается

Считать «экономию от OCR» в процентах бессмысленно — цифра получится любой. Считать надо в секундах на поле, и допущения я назову вслух, чтобы вы подставили свои.

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

Операция Руками С распознаванием
Открыть скан, найти нужное поле глазами 5–8 сек 5–8 сек (никуда не делось)
Перенести 12–15 цифр в форму 20–40 сек
Сверить набранное с картинкой 10 сек 7–10 сек
Запрос к сервису на страницу около 1 сек
Итого на документ из 4 полей около 3,5 минут около 1 минуты

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

Стоимость запросов при этом остаётся далеко позади стоимости часов. Одна страница — один запрос, сто двадцать документов по одной-две страницы — меньше трёхсот запросов в день. Цена одного запроса — десятки копеек, актуальная цифра на странице тарифов: она меняется, а статья остаётся. Триста запросов в день — это порядка полутора сотен рублей, то есть примерно стоимость получаса работы человека против пяти сэкономленных часов. Разрыв не астрономический, но десятикратный, и он устойчив к тому, что цена подрастёт.

Как это встроить, чтобы работало

Порядок такой, и последний шаг важнее первых.

Шаг первый: приведите страницу в разумный вид. На вход метода уходит картинка в Base64, и после декодирования она должна укладываться в десять мегабайт. Скан А4 в 300 dpi весит 2–5 МБ и проходит спокойно, так что бороться за килобайты не нужно. Полезно другое: не гнаться за разрешением вверх. 600 dpi в цвете там, где хватает 200–300 в сером, не добавляет точности — этот запас нужен мелкому шрифту в сносках, а не основному тексту, — зато удлиняет обработку каждой страницы, и на пакете из тысячи листов разница уже измеряется часами. Заодно выпрямите перекос: кривой скан с тенью от сгиба портит результат сильнее, чем что-либо ещё.

Шаг второй: отправьте страницу в сервис «Распознавание текста». Целиком, одним запросом. Карта зон с координатами прямоугольников под каждый тип документа не нужна: разметку макета сервис строит сам. Резать страницу на куски имеет смысл в одном случае — когда конкретное поле на плохом скане читается неуверенно и вы хотите переспросить по нему отдельно, указав mode: "digits" там, где ожидаются только цифры. Тогда цифра «0» перестаёт превращаться в букву «O».

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

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

Всё, что не прошло проверку, уходит человеку. Всё, что прошло, — тоже уходит человеку, но уже в режиме «посмотри и нажми Enter».

Код: страница и один запрос

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

Метод один: POST /api/ocr/image-to-text. В теле — JSON с картинкой в Base64.

Поле запроса Тип Что делает
image строка, обязательное Картинка в Base64. Принимается и «голая» строка, и data-URL вида data:image/png;base64,... — префикс сервис срежет сам
mode строка, по умолчанию "auto" Режим распознавания: auto — сервис сам различает строку и страницу; document — страница целиком со структурой; table — только таблица; formula — формула в LaTeX; line — короткая строка; digits — короткая строка из одних цифр. Указывать явно стоит, когда переспрашиваете по вырезанному полю или когда важна предсказуемая стоимость
Распознать страницу накладной

# Картинка кодируется в Base64 и уходит в теле запроса.
# -w0 нужен, чтобы base64 не разбил строку переносами.
IMG=$(base64 -w0 page_01.jpg)

curl -X POST "https://atlorium.com/api/ocr/image-to-text" \
     -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     -H "Content-Type: application/json" \
     -d "{\"image\":\"$IMG\"}"
        

import base64
import requests

BASE = "https://atlorium.com"
HEADERS = {"Authorization": "Bearer ak_sandbox_demo_mockdata_v1"}


def recognize(img_bytes: bytes, digits_only: bool = False) -> str | None:
    """Возвращает распознанный текст либо None, если распознать не удалось."""
    resp = requests.post(
        f"{BASE}/api/ocr/image-to-text",
        json={
            "image": base64.b64encode(img_bytes).decode(),
            "mode": "digits" if digits_only else "auto",
        },
        headers=HEADERS,
        timeout=30,
    )

    # 503 — источник недоступен. Кредиты при этом не списываются,
    # так что повторить запрос позже ничего не стоит.
    if resp.status_code == 503:
        return None
    resp.raise_for_status()

    data = resp.json()
    # recognized=false означает «читаемого текста не нашли». Это не ошибка
    # запроса и не повод для retry: тот же файл даст тот же результат.
    if not data["recognized"]:
        return None

    # truncated=true — страница длиннее предела одного ответа, текст обрезан.
    # Внешне такой ответ неотличим от полного, поэтому проверять поле обязательно:
    # молча принять обрезанный документ хуже, чем не принять никакой.
    if data.get("truncated"):
        send_to_operator("Страница распознана не полностью")
        return None

    return data["text"]


with open("page_01.jpg", "rb") as f:
    page = recognize(f.read())

if page is None:
    send_to_operator("Страница не распознана")
else:
    # Ищем реквизит по подписи в тексте, а не по координатам на картинке.
    inn = find_after_label(page, "ИНН")

    if inn is None or not valid_inn_checksum(inn):
        send_to_operator(f"ИНН не подтверждён: {inn}")
    else:
        save(inn)
        

using System.Net.Http.Json;
using System.Text.Json;

var http = new HttpClient { BaseAddress = new Uri("https://atlorium.com") };
http.DefaultRequestHeaders.Add("Authorization", "Bearer ak_sandbox_demo_mockdata_v1");

var bytes = await File.ReadAllBytesAsync("page_01.jpg");

var payload = new
{
    image = Convert.ToBase64String(bytes),
    mode = "document",   // страница целиком: и цифры, и текст
};

var resp = await http.PostAsJsonAsync("/api/ocr/image-to-text", payload);

// 503 — сервис недоступен или перегружен, списания не было: можно повторить позже.
if (resp.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable)
    return null;

resp.EnsureSuccessStatusCode();
var card = await resp.Content.ReadFromJsonAsync<JsonElement>();

var recognized = card.GetProperty("recognized").GetBoolean();
// text приходит null, когда recognized=false, — читать его без проверки нельзя.
var text = recognized ? card.GetProperty("text").GetString() : null;

// format сообщает, ЧТО лежит в text: "markdown" (страница с разметкой),
// "plain" (короткая строка) или "latex" (формула). Выбирать разбор по этому
// полю надёжнее, чем угадывать по содержимому.
var format = card.GetProperty("format").GetString();

// units — во сколько единиц работы обошёлся запрос: по ним он и тарифицирован.
// Короткая строка это всегда 1, страница — столько, сколько на ней областей.
var units = card.GetProperty("units").GetInt32();

// truncated=true — текст обрезан по пределу длины, страница неполная.
var truncated = card.GetProperty("truncated").GetBoolean();

var elapsedMs = card.GetProperty("elapsedMs").GetInt64();
        

{
  "recognized": true,
  "format": "markdown",
  "truncated": false,
  "text": "## Товарная накладная № 4417 от 12.03.2026\n\nПоставщик: ООО «Ромашка», ИНН 7707083893\n\n| № | Наименование | Кол-во | Сумма |\n|---|---|---|---|\n| 1 | Короб 600x400 | 120 | 43 200,00 |\n| 2 | Стретч-плёнка | 15 | 8 250,00 |\n\nИтого: 51 450,00",
  "elapsedMs": 1180
}

# Читаемого текста не нашлось — плата за такой запрос не берётся:
{
  "recognized": false,
  "text": null,
  "format": "plain",
  "truncated": false,
  "elapsedMs": 940
}
        

Тот же вызов на шести языках (Python, TypeScript, Go, Java, C#, PHP) — в репозитории image-ocr-api-client. Примеры запускаются сразу: ключ в них уже стоит демонстрационный.

Полей в ответе пять, и одну вещь стоит принять как данность при проектировании: вся структура страницы приезжает разметкой внутри text, отдельных полей под координаты слов или список вариантов в ответе нет. Что делать с каждым:

Поле ответа Что означает Что делать
recognized: true Текст распознан, он лежит в text Вытащить нужные реквизиты по подписям, прогнать через проверку формата, затем показать оператору на сверку
recognized: false Читаемого текста на изображении не нашлось. text при этом null Отправить страницу человеку на ручной ввод. Повторять запрос смысла нет — картинка та же. Списания за такой запрос не происходит
text Содержимое страницы с разметкой Markdown Не сохранять как есть в реквизиты. Разобрать, убрать пробелы по краям, проверить длину и контрольную сумму
format "markdown" — страница с разметкой; "plain" — простая строка; "latex" — формула Выбирать по нему ветку разбора вместо угадывания по содержимому. Список значений может пополниться, поэтому незнакомое значение разумно обрабатывать как plain
mode Режим, в котором изображение распознано на самом деле Совпадает с запрошенным, кроме auto — там видно решение сервиса. Если результат оказался не тем, что ожидалось, смотреть надо сюда: почти всегда автоопределение приняло страницу за строку или наоборот
units Единицы работы, в которые обошёлся запрос По ним запрос и тарифицирован. Короткая строка — всегда 1, страница — столько, сколько на ней распознано областей. Удобно сверять расход по пакету, не заглядывая в личный кабинет
truncated true — текст обрезан по пределу длины либо по потолку числа областей, страница распознана НЕ полностью Проверять обязательно. Обрезанный ответ внешне неотличим от полного, и без этой проверки потеря части документа пройдёт незамеченной. Такую страницу отправлять на ручную сверку
elapsedMs Сколько заняло распознавание — вместе с ожиданием очереди на GPU, а не только сам инференс Складывать в метрики. Именно потому, что очередь входит в цифру, по ней и подбирается параллелизм: пополз вверх при тех же картинках — конвейер упёрся в пропускную способность, и наращивать потоки дальше бесполезно

Из кодов ошибок в конвейере важны три. 400 — картинка не передана, битый Base64 или файл больше десяти мегабайт: чинится на вашей стороне, кредит не тратится. 429 — упёрлись в лимит запросов, конвейер надо притормозить. 503 — движок временно недоступен или перегружен, и вот здесь важная деталь: деньги за такой запрос не списываются. Пакет можно спокойно поставить в очередь и прогнать через полчаса.

Отдельно про темп. На клиента отведено 60 запросов в минуту — ровно страница в секунду, и это осознанно много: сервис задуман под пакетную оцифровку, где меньший темп просто бессмысленен. Но параллелить сверх этого не нужно даже не из-за 429: страницы считает одна видеокарта, и запросы сверх её пропускной способности встают в очередь. Практический вывод для конвейера — увеличивать параллелизм, пока растёт скорость пакета, и останавливаться, когда начинает расти elapsedMs. Момент, когда время ответа поползло вверх при тех же картинках, и есть точка насыщения; дальше вы добавляете не производительность, а длину очереди.

И одна честная оговорка про песочницу. Структуру ответа мок повторяет точно, но вашу картинку он не читает — использует её лишь как зерно генератора, поэтому текст в ответе никак не связан с тем, что на изображении. И мок всегда отвечает recognized: true с truncated: false: ветки recognized: false и обрыва по длине на демо-ключе воспроизвести нельзя, тестировать их придётся подстановкой ответа в своём коде.

Чего распознавание не умеет

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

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

Оценки уверенности нет. Есть только recognized: да или нет. Промежуточного «распознал, но не уверен» не существует, и порог «отправить оператору при уверенности ниже 0,8» построить не на чем. Роль этого порога у вас играют проверки формата и контрольные суммы реквизитов — их придётся написать. Это не отговорка: для ИНН, счёта и суммы контрольная сумма надёжнее любого внутреннего скора модели, потому что проверяет само значение, а не то, насколько уверенно оно прочиталось.

Юридической значимости у результата нет. Распознанный текст — это удобная копия для поиска, ввода и сверки, а не документ. Для спора, суда и проверки основанием остаётся оригинал, и заменить его выгрузкой из конвейера нельзя.

Тяжёлые PDF — отдельная история. Сама модель PDF на вход принимает, но метод image-to-text работает с растровой картинкой, поэтому многостраничный файл вы рендерите в страницы у себя и отправляете постранично. Это не временная недоделка, а осознанная граница: постраничный рендер на своей стороне даёт вам контроль над dpi и цветом — то есть над скоростью и ценой пакета. Для случаев, где важны электронные подписи, слои и сложная вёрстка, планируется отдельный сервис — обещать его как готовый я не буду, потому что его пока нет.

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

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

Частые вопросы

Частые вопросы

Как распознать текст со скана документа через API?

Отправьте POST на /api/ocr/image-to-text с картинкой страницы в Base64 в поле image. В ответ придёт recognized и содержимое страницы в поле text с разметкой Markdown: заголовки, абзацы и таблицы сохраняют структуру. Резать страницу на зоны заранее не нужно, макет модель размечает сама.

Распознаются ли таблицы в накладных и актах?

Да. Движок распознаёт таблицы и формулы и возвращает их разметкой Markdown с сохранением столбцов и порядка чтения. Отдельно вырезать ячейки не требуется. Значения из таблицы всё равно стоит проверить у себя: сумма строк должна сходиться с итогом.

Можно ли отправить PDF-файл?

Нет, метод image-to-text принимает растровое изображение страницы. Многостраничный PDF нужно отрендерить в картинки на своей стороне и отправлять постранично — заодно вы сами управляете dpi и цветом, а значит скоростью и ценой пакета. Для тяжёлых файлов с электронными подписями и сложной вёрсткой планируется отдельный сервис.

Что делать, если текст не распознан?

Ответ придёт с recognized: false и text: null, и плата за такой запрос не взимается. Повторять тот же запрос бесполезно — картинка не изменилась. Правильная реакция: отправить страницу оператору на ручной ввод, а сам файл посмотреть глазами: чаще всего дело в перекосе, тени или слишком низком разрешении.

Есть ли в ответе оценка уверенности распознавания?

Нет. В ответе есть recognized, text, format, truncated и elapsedMs — промежуточного значения между «распознал» и «не распознал» среди них нет, поэтому порог по уверенности построить не на чем. Его роль выполняют проверки формата поля и контрольные суммы реквизитов — ИНН, расчётного счёта, итоговой суммы.

Куда уходят мои документы при распознавании?

Никуда за пределы нашей инфраструктуры. Под сервисом работает наша модель распознавания, развёрнутая на собственном сервере Atlorium, а не сторонний API распознавания. Страница обрабатывается у нас и результат возвращается в том же HTTP-ответе.

Какой максимальный размер картинки?

Десять мегабайт после декодирования из Base64. Скан А4 в 300 dpi укладывается в этот предел с запасом, так что специально сжимать страницу не нужно. Поднимать разрешение выше 300 dpi смысла нет: распознаётся не лучше, а времени на обработку уходит больше — на пакете это заметно.

Сколько стоит распознавание и платится ли неудачная попытка?

Первые пять распознаваний в сутки бесплатны (гостю — одно), дальше оплата идёт по факту запроса, без подписки и абонентской платы; актуальная цена указана на странице тарифов. Списывается только успешное распознавание: при recognized: false и при ошибке 503, когда сервис недоступен или перегружен, деньги не берутся.

Можно ли попробовать до регистрации?

Да, двумя способами. Публичный демо-ключ ak_sandbox_demo_mockdata_v1 работает без аккаунта и отдаёт моки — сгенерированные, но детерминированные ответы, на которых удобно писать тесты конвейера; своё изображение он не читает. А чтобы посмотреть настоящее распознавание на своём документе, есть бесплатная суточная квота: одно распознавание гостю и пять авторизованному пользователю.

Сервисы из этой статьи

Распознавание текста с изображения

OCR «картинка → текст»: печатный текст, таблицы и формулы со скана или фото через REST API; плата только за успешное распознавание

Попробовать прямо сейчас — без регистрации

Демо-ключ ak_sandbox_demo_mockdata_v1 — публичный и общий для всех. С ним API отвечает моками: данные правдоподобные, но сгенерированные, и они не меняются от запроса к запросу — на них удобно писать тесты. Настоящие данные приходят с личным ключом.

curl -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     https://atlorium.com/openapi/ocr_ru.json
Полезно? Перешлите коллеге: Telegram VK

Восстанавливаем соединение…

Похоже, связь с сервером ненадолго прервалась. Переподключаемся автоматически — пожалуйста, подождите несколько секунд.

Не удалось переподключиться

Проверьте интернет-соединение. Можно повторить попытку или обновить страницу.

Сессия устарела

Соединение восстановлено, но сессию нужно перезагрузить. Обновите страницу, чтобы продолжить.