Я хотел распознать три колонки накладной. OCR оказался самой простой частью

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

Первый сценарий оказался очень узким. Есть фотография УПД. В таблице нужно взять ставку НДС из колонки 7, сгруппировать строки по этой ставке, сложить значения колонок 8 и 9 и сверить полученные суммы с итогом документа.

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

фотография УПД
→ найти три нужные колонки
→ прочитать числа
→ сгруппировать по ставке НДС
→ проверить суммы

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

На старте мне казалось, что самая сложная часть здесь — OCR. Дальше нужно будет только аккуратно распарсить числа и посчитать суммы.

Через несколько часов первая версия попыталась выделить десятки гигабайт памяти. А ещё через серию экспериментов выяснилось, что распознавание символов — как раз одна из самых простых частей задачи.

Первая версия: отдать страницу PaddleOCR

Проект я назвал PaperLedger. В первом техническом задании архитектура была вполне ожидаемой: OpenCV для подготовки фотографии, PaddleOCR для распознавания, готовый table recognition pipeline для восстановления структуры таблицы, затем нормализация, группировка и проверка результата.

Изначально схема выглядела так:

upload
→ OpenCV preprocessing
→ PaddleOCR / table recognition
→ таблица
→ колонки 7/8/9
→ normalization
→ grouping
→ validation
→ HTML / JSON / XLSX

Причём готовый table recognition был не запасным вариантом, а основным. Классическую геометрию я рассматривал скорее как fallback, если нейросетевая часть не справится.

Coding agent собрал первый MVP. После декодирования фотографии шёл поиск листа, perspective warp, deskew, CLAHE, а затем два полностраничных прохода: TableRecognitionPipelineV2 для структуры таблицы и отдельный PaddleOCR(lang="ru") для текста.

На бумаге решение выглядело разумно. На реальном фото с телефона — уже нет.

Файл весил около 5 МБ. Во время обработки PaddleOCR попытался выделить примерно 44 ГБ памяти и запрос завершился ResourceExhaustedError.

Первая гипотеза была очевидной: слишком большая фотография. Поэтому агент добавил ограничение размера — длинная сторона приводилась к 2600 пикселям ещё до OCR. На синтетическом большом изображении это действительно убрало падение: запрос стал завершаться. Правда, занимал около 44 секунд и всё ещё доходил примерно до 10 ГБ RSS.

В тот момент это выглядело как рабочий первый фикс. Большая фотография стала меньше, приложение перестало падать — можно двигаться дальше.

Но затем тот же класс проблемы повторился на другом реальном документе.

И вот здесь началась уже не разработка «OCR-сервиса», а нормальное расследование.

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

После повторного обвала агент стал запускать эксперименты в systemd-run с жёстким MemoryMax и отключённым swap. Идея простая: если очередная гипотеза снова потребует десятки гигабайт, пусть операционная система убьёт конкретный процесс, а не превратит ноутбук в кирпич на несколько минут.

Первый воспроизводимый эксперимент показал неприятную вещь: даже изображение размером 1462×2600 всё равно превышало лимит в 14 ГБ.

До начала inference загрузка моделей занимала около 85 секунд, RSS после загрузки был примерно 1.97 ГБ. А затем первый полностраничный neural inference выбивал процесс за 14 ГБ.

Когда разобрали состав pipeline, причина стала понятнее. Отдельный PaddleOCR(ru) запускал свой детектор текста на всей странице. TableRecognitionPipelineV2, в свою очередь, содержал собственный набор моделей для layout, OCR и распознавания структуры таблицы. Получалось, что страницу мы фактически прогоняли через несколько тяжёлых моделей, причём часть работы дублировалась.

Это не означало, что PaddleOCR «плохой». Просто для моей задачи архитектура была неадекватной.

Мне не нужно было понимать всю страницу. Мне требовались несколько колонок таблицы, причём их положение можно было найти геометрически.

После этого схема поменялась принципиально:

ORIGINAL
↓
маленькая working copy
↓
classical CV: geometry
↓
координаты нужных областей
↓
ORIGINAL
↓
маленькие crops
↓
text recognition model

Уменьшенная копия нужна для дешёвой геометрии. Оригинал нужен для чтения. Нейросеть больше не должна видеть всю страницу.

Это правило — working image для геометрии, original для распознавания — дальше пережило почти все последующие изменения pipeline.

Позже пиковая память на локальном recognition была около 627 МБ вместо ситуации «больше 14 ГБ и процесс убит».

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

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

Выпрямить лист — не значит выпрямить таблицу

Первая геометрическая версия делала то, что обычно ожидаешь от document scanner: находила контур листа на уменьшенной копии, строила perspective transform и выпрямляла страницу.

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

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

Попробовали обычный rotation по углу горизонталей. Горизонтали действительно стали лучше, зато вертикали наклонились примерно на 0.31°. Значит, мы имеем дело не просто с поворотом изображения. Искажение было ближе к shear или перспективе.

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

Я спросил агента, не нужно ли вернуться назад и добиться, чтобы первый шаг сразу отдавал нормальную геометрию.

Агент предложил перенести измерение наклона раньше: найти лист по четырём углам, на уменьшенной копии измерить линии таблицы, объединить perspective и shear в одну матрицу и сделать один warp.

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

И тут я вспомнил старый проект.

При чём здесь Sudoku

Много лет назад я делал распознавание Sudoku. Там тоже есть фотография, перспективные искажения и регулярная сетка.

Я написал агенту примерно следующее:

Я делал алгоритм распознавания Sudoku. Сначала на неоткадрированном кадре искал вертикальные и горизонтальные линии в некотором диапазоне углов, потом находил их пересечения и уже по ним понимал, какое преобразование нужно применить к картинке, чтобы её выровнять.

Смысл был не в том, чтобы буквально перенести код Sudoku. Смысл был в выборе опорного объекта.

Если мне нужна правильная геометрия таблицы, зачем выравнивать бумагу? Нужно выравнивать саму таблицу.

После этой реплики подход изменился:

исходный кадр
→ LSD line segments
→ горизонтальные и вертикальные линии
→ их пересечения
→ узлы таблицы
→ homography / RANSAC
→ один warp оригинала

Линии искались прямо на ещё невыпрямленном кадре и допускали некоторый диапазон углов. Затем короткие отрезки объединялись в длинные линии, горизонтали пересекались с вертикалями, а получившиеся точки становились геометрическими опорами.

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

На отладку детектора тоже ушло несколько итераций. LSD находил обе кромки толстой линии. Цифры в узкой колонке могли создавать ложные сегменты. Нужно было отличать реальную структуру таблицы от визуального шума внутри неё.

Но главным результатом оказался даже не новый алгоритм, а сравнение старого и нового одной и той же метрикой.

Вариант Наклон горизонталей / вертикалей Средняя невязка узлов
Без выравнивания 0.032° / 0.053° 0.76 px
По контуру листа 0.374° / 0.010° 1.97 px
Контур + shear 0.105° / 0.004° 0.98 px
По сетке таблицы 0.010° / 0.002° 0.26 px

Самая неприятная строка здесь — вторая.

Выравнивание по контуру листа было количественно хуже, чем вообще ничего не делать.

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

Это был полезный урок: preprocessing — не бесплатное улучшение изображения. Любое преобразование должно оцениваться относительно того признака, который нужен дальше.

Новый метод стоил примерно дополнительных 0.8 секунды на страницу на том этапе, но средняя невязка узлов упала с 1.97 до 0.26 пикселя.

Казалось бы, теперь сетка найдена. Значит, можно вырезать ячейки и отдать их OCR.

Оказалось, что даже здесь нескольких пикселей достаточно, чтобы сломать число.

Один пиксель на working image — несколько пикселей на оригинале

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

На первый взгляд всё работало. Но в некоторых денежных значениях исчезала последняя цифра.

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

Первый вариант вырезал клетки непосредственно по координатам сетки. Его пришлось выбросить.

Следующий вариант стал двухуровневым. Сначала сетка на working image давала приблизительную границу. Затем уже в оригинале вокруг неё открывалось небольшое окно примерно ±10 пикселей, где алгоритм искал реальную печатную линию. И только после этого строился crop.

Для контроля ввели простую метрику edge_ink: сколько вырезов имеют чернила непосредственно у края, то есть потенциально обрезанный символ.

До refinement таких случаев было около 67 из 84. После — 3 из 84, причём оставшиеся три оказались пятнами и следами ручки, а не действительно отрезанными цифрами.

Так появился второй уровень coarse-to-fine:

страница: coarse geometry
→ ячейка: local boundary refinement

И только после этого OCR наконец стал получать действительно локальные фрагменты изображения.

Нейросеть наконец увидела только то, что нужно прочитать

После отказа от full-page pipeline исчезла необходимость в detector и table recognition во время чтения значений. Оставалась одна небольшая text recognition model.

Я сравнил несколько вариантов входа: распознавать каждую ячейку отдельно или подавать целую полосу из нужных колонок; grayscale или бинаризированное изображение.

Здесь обнаружилась ещё одна полезная асимметрия. Бинаризация отлично помогала геометрии: линии таблицы становились простыми и контрастными. Но OCR на тех же бинарных данных работал хуже. Recognition лучше чувствовал себя на grayscale.

То есть одна и та же «улучшенная» картинка не обязана быть хорошей для всех стадий pipeline.

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

Если распознаётся длинная полоса, одна потерянная запятая или один неверный separator способен нарушить разбор сразу нескольких соседних значений. У отдельной ячейки ошибка локализована: есть конкретный bbox, конкретный crop и конкретный результат OCR. Его можно независимо проверить и сохранить как evidence.

Для бухгалтерских данных такая трассируемость оказалась важнее небольшого выигрыша на одной тестовой фотографии.

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

Самое большое улучшение OCR оказалось обычным crop

Ошибки странно группировались: большинство возникало в строках двойной высоты.

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

Формально мы подавали модели правильную ячейку. Практически — заставляли её читать слишком мелкий текст.

Перед recognition добавили ещё один шаг: поиск связных компонентов чернил внутри ячейки и tight crop вокруг самого значения.

Результат на размеченном golden-документе изменился заметно:

отдельные ячейки: 76 → 82 из 83
полоса:           79 → 83 из 83

Одновременно для recognition model удалось включить MKL-DNN. При тех же tight-crop вырезах время отдельного вызова сократилось примерно с 155 до 44 мс для отдельных ячеек и с 366 до 93 мс для полосы — то есть в 3.5–4 раза.

То есть новый OCR не понадобился. Мы просто стали показывать старому OCR более правильное изображение.

Конечно, новый preprocessing тут же создал новый класс ошибок. Tight crop иногда принимал точку, след ручки или остаток линии сетки за часть текста и расширял рамку куда не надо. Пришлось отдельно ужесточать правила выбора компонентов.

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

Если это ставка НДС, зачем разрешать модели букву Q?

После tight crop оставалась очень показательная ошибка:

10% → 1Q%

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

Можно было написать постобработку Q → 0. Но это означало бы кодировать одну конкретную ошибку одной конкретной фотографии.

Зато я точно знаю семантику поля.

Если это денежная сумма, там допустимы цифры и разделители. Если это ставка НДС — цифры, знак процента и отдельный текстовый вариант «без НДС». Почему вообще позволять decoder выбирать остальные символы алфавита?

Ограничение внесли на уровне CTC decoding. Для конкретного типа поля decoder рассматривал только допустимые символы.

На golden-документе это закрыло оставшуюся ошибку и дало 83 совпадения из 83 размеченных значений.

Важно, что это не стало глобальным правилом. В строке номеров колонок УПД встречаются обозначения вроде 1а, 1б, 10а. Если включить numeric whitelist везде, правильный текст начнёт портиться уже из-за нашей уверенности в том, что там «должны быть цифры».

То есть domain constraints оказались полезны только там, где семантика поля действительно известна.

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

«Хорошая фотография» — это сколько?

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

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

Для текущей модели и этих документов рабочая нижняя граница оказалась примерно на уровне 7–8 пикселей высоты цифры. Ниже примерно 6 пикселей качество быстро деградировало. Около 25% свободного поля вокруг текста оказалось хорошим режимом для recognition.

На golden-фотографии был примерно 2.5-кратный запас по разрешению относительно этого порога.

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

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

Второй документ быстро уничтожил ощущение, что задача решена

После результата 83/83 очень легко решить, что OCR уже работает.

Поэтому следующим шагом я принёс другие реальные фотографии.

Одна из них оказалась не УПД, а ТОРГ-12. Документ был повернут, структура колонок другая, бумага местами выгнута.

Сначала появилась задача ориентации. Готовый classifier PaddleOCR на центральном crop ошибся в одном случае из 24 искусственно созданных поворотов. Причина была прозаической: центр страницы не гарантирует, что там есть нормальный текст. Там могли оказаться подписи или почти пустая область.

Вместо замены classifier изменили способ подачи данных. Страницу разбили на сетку 3×3, пустые области пропускались, а остальные голосовали за ориентацию. На том же экспериментальном наборе получилось 24 из 24.

Тип документа тоже не потребовал отдельной нейросети. Уже существовала строка с номерами колонок. УПД содержит характерные обозначения вроде А и 10а, ТОРГ-12 — последовательность колонок до 17 без этих признаков. Этого оказалось достаточно для консервативного rule-based определения формы.

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

Для бухгалтерского инструмента это важная разница. Ошибка «не смог определить» неприятна. Ошибка «уверенно обработал документ как другой тип» намного опаснее.

Но самая интересная проблема нового документа была вообще не в классификации и не в OCR.

Можно правильно распознать все цифры и собрать строку, которой не было на бумаге

На выгнутом ТОРГ-12 global row detection нашёл 16 строк вместо реальных 18. В двух местах соседние физические строки склеились, потому что из-за кривизны граница не проходила достаточно уверенно через всю ширину таблицы.

Сначала это выглядит как ещё одна ошибка segmentation.

Но последствия намного хуже обычной ошибки OCR.

Нужные колонки обрабатываются независимо. Если границы строк в них определены немного по-разному, можно взять сумму из одной физической строки, НДС — из соседней, а затем собрать их в один структурированный объект.

Все символы при этом могут быть распознаны идеально.

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

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

Полностью выпрямлять сильно выгнутый лист оказалось не обязательно. Мы снова сузили задачу.

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

После перехода к local row geometry алгоритм нашёл все 18 физических строк, а случаи смешивания значений между строками исчезли на этом документе.

Паттерн повторился уже в третий раз:

не решать всю страницу,
если задача локальна

Сначала это спасло память. Потом помогло ячейкам. Теперь — строкам на выгнутой бумаге.

Когда OCR стал последним bottleneck

После всех этих изменений время обработки на текущем сайте было примерно 4.4–4.8 секунды на документ. Из них 75–80% уже занимала именно recognition network.

То есть мы наконец дошли до ситуации, которую я ожидал в самом начале: основная стоимость действительно была в OCR.

Теперь имело смысл сравнивать модели.

Агент прогнал семь вариантов recognition, включая разные поколения PaddleOCR и Tesseract, в двух режимах — отдельные cells и полоса — на двух реальных документах.

Самым быстрым оказался PP-OCRv6_tiny, но скорость была недостаточным критерием. Модель путала 6 и 8, а также портила строку номеров колонок.

en_PP-OCRv3_mobile_rec оказался заметно быстрее исходной модели — примерно в 1.8–2.6 раза в разных измерениях — и давал хороший результат на денежных значениях. Но оставалась системная ошибка на ставке НДС: проблемный 10% превращался в 19%.

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

Допустимых значений конечное число:

0%
5%
7%
10%
20%
22%
без НДС

Вместо обычного посимвольного decoding для этого поля стали считать CTC probability каждой законной записи и выбирать наиболее вероятный вариант.

Это уже не whitelist символов. Decoder знает не просто допустимый алфавит, а полный словарь допустимых значений поля.

На проблемном мазке свободный decoder выбирал 19%, а среди реальных ставок более вероятным оказывался 10%.

В результате en_PP-OCRv3_mobile_rec остался моделью по умолчанию. На текущей экспериментальной коллекции из двух документов получилось 135 совпадений из 135 размеченных значений. Время recognition-related обработки изменилось с 4.84 до 2.81 секунды на ТОРГ-12 и с 6.06 до 3.19 секунды на УПД относительно предыдущей модели — примерно ×1.7–1.9.

Здесь важно не прочитать эти числа как «PaperLedger имеет 100% accuracy». Два документа и 135 значений — это тестовая коллекция конкретного этапа, а не статистически значимая оценка универсального OCR.

Более того, самый быстрый вариант мы сознательно не выбрали: гибрид с tiny-моделью терял одно реальное значение. Минимальный latency любой ценой в такой задаче не интересен.

Во что в итоге превратился pipeline

Если свернуть всю эту историю до архитектурной последовательности, получилось примерно так:

full-page PaddleOCR + table recognition
                ↓
working image для geometry + original для OCR
                ↓
выравнивание по сетке вместо контура листа
                ↓
coarse grid + local boundary refinement
                ↓
text recognition только на crops
                ↓
tight crop до реального текста
                ↓
field-aware CTC constraints
                ↓
orientation + определение типа документа
                ↓
local row geometry
                ↓
быстрая recognition model + dictionary decoding

Несколько чисел хорошо показывают масштаб изменений.

Исходный full-page pipeline превышал лимит 14 ГБ и завершался kill процесса. Локальный recognition в одном из контрольных прогонов имел пик около 627 МБ.

Средняя невязка узлов при выравнивании по контуру листа была 1.97 пикселя. При выравнивании по самой сетке — 0.26 пикселя.

На одном размеченном golden-документе цепочка улучшений recognition прошла от 76 правильных значений из 83 в раннем варианте (отдельные ячейки, без tight crop) до 83 из 83 после подготовки crop и domain-aware decoding.

На следующей небольшой коллекции после смены recognition model и словаря ставок получилось 135 из 135 при заметном снижении времени.

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

Память удалось снизить, потому что нейросеть перестала видеть всю страницу. Геометрия улучшилась, потому что ориентиром стала таблица, а не бумага. OCR улучшился, потому что crop стал правильнее. Ошибка Q исчезла, потому что decoder получил семантику поля. Кривой ТОРГ-12 удалось разобрать, потому что global problem превратился в local problem.

Что в этой истории делал coding agent

Весь проект я делал с coding agent, и по ходу работы его роль заметно изменилась.

В начале взаимодействие было обычным:

я формулирую задачу
→ агент реализует
→ запускаем
→ смотрим результат

Так появился и первый full-page pipeline. Он вполне соответствовал исходному техническому заданию — в нём я сам просил использовать table recognition PaddleOCR как основной путь.

После первых сбоев режим стал другим.

Вместо больших команд вроде «почини распознавание» мы начали вести журнал экспериментов. Перед изменением фиксировалась гипотеза, после — конкретные измерения и артефакты. Агент запускал варианты под memory limit, считал время и RSS, сохранял промежуточные изображения, crops и overlays, сравнивал реализации и после успешного эксперимента переносил решение в основной pipeline.

То есть его наиболее полезная роль постепенно стала похожа не на «программиста, которому один раз отдали ТЗ», а на очень быстрого лабораторного исполнителя.

Это не значит, что я всё время исправлял агента. Например, настоящую причину OOM после первого неудачного resize нашёл именно агент в ходе измерений. Он же последовательно прогонял альтернативы и отбрасывал их по метрикам.

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

В итоге наиболее продуктивный цикл стал выглядеть примерно так:

наблюдение
→ гипотеза
→ измеримый эксперимент
→ агент быстро реализует и прогоняет варианты
→ timings / RSS / crops / diagnostics
→ интерпретация
→ следующая гипотеза

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

OCR оказался самой простой частью

В начале я мысленно представлял систему так:

image → OCR → data

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

image
→ geometry
→ physical rows and cells
→ OCR
→ domain constraints
→ structured data
→ validation

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

А большая часть надёжности появилась вокруг неё.

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

При этом PaperLedger пока нельзя называть готовым продуктом.

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

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

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

Но это уже другая история.