Я проектирую мобильное приложение для обмена контактами на конференциях: показываешь QR, собеседник сканирует, контакт у него. В версии 2.0 добавили то, о чём просили с самого начала, — распознавание бумажных визиток. Сфотографировал карточку, получил контакт с разложенными полями.
Звучит как задача на вечер: берёшь облачный OCR, отправляешь фото, получаешь текст. У нас она заняла около трёх недель, и почти всё интересное случилось не там, где я ожидал. Ниже — что мы выбрали, что отвергли и сколько это стоило в цифрах.

Почему не облако
Первое ограничение не техническое. Фото чужой визитки — это персональные данные третьего лица. Как только оно попадает к нам на сервер, мы становимся оператором этих данных со всеми обязанностями по 152-ФЗ. Фраза «мы ничего не храним, только распознаём» этого не меняет. Google Cloud Vision добавляет сверху трансграничную передачу, Yandex Vision — поручение обработки.
Поэтому решение было принято раньше любых замеров: фото визитки не покидает телефон. Чужие контакты вообще не уезжают на наш сервер и живут только на устройстве.
У этого решения есть следствие, которое легко пропустить: обучающей выборки у нас не будет никогда. Обычная петля «пользователи исправляют ошибки → дообучаем модель» закрыта, и закрыли её мы сами. Всё дальнейшее делалось с этим ограничением.
Асимметрия платформ
Приложение написано на React Native, так что первым делом я посмотрел штатные средства обеих платформ.
|
|
iOS |
Android |
|---|---|---|
|
Кириллический OCR |
системный Vision, |
в ML Kit нет |
|
Вес в дистрибутиве |
0 МБ |
свой движок и модели |
У ML Kit пять распознавателей текста: латиница, китайский, деванагари, японский и корейский. Кириллицу не понимает ни один. Класса вроде CyrillicTextRecognizerOptions не существует. На Android бесплатного штатного OCR для русского языка просто нет.
Для Android взяли PaddleOCR v5 в мобильной комплектации: детектор строк PP-OCRv5_mobile_det и распознаватель eslav_PP-OCRv5_mobile_rec, обученный на восточнославянских языках. У PaddlePaddle есть официальные конверсии в ONNX, поэтому исполняет их ONNX Runtime. Две модели весят 12,7 МБ.
Что отвергли
Мне кажется, эта таблица — самое полезное в статье. Каждый вариант мы честно рассмотрели, и каждый проиграл по конкретной причине.
|
Вариант |
Почему нет |
|---|---|
|
Облачный OCR |
152-ФЗ, см. выше |
|
ML Kit |
нет кириллицы |
|
Локальная LLM для раскладки по полям |
задача — присвоить 6–15 строкам одну из восьми меток, рассуждать не нужно. LLM просит за это 125–800 МБ веса, 5–20 с на процессоре среднего телефона и 0,3–1 ГБ памяти, которую она делит с камерой и WebView. И она охотно достраивает: допишет отчество, которого на карточке нет, или переведёт «ООО Ромашка» в «Romashka LLC» |
|
Мультимодальная модель «фото → JSON» |
около 2 ГБ, и врёт в цифрах телефона — ровно там, где ошибку не видно глазом |
|
Gemini Nano |
ноль байт в дистрибутиве, но AICore есть только на части флагманов |
Python не нужен нигде
Изначально я собирался гонять PaddleOCR на ноутбуке под Python, чтобы мерить качество. Оказалось, что это лишнее звено. Раз модели есть в ONNX, весь конвейер можно написать на TypeScript прямо в коде приложения: подготовку кадра, разбор выхода детектора, CTC-декодирование, раскладку по полям.
Стенд на ноутбуке и приложение на телефоне исполняют один и тот же код. Различаются они одной строкой: onnxruntime-node против onnxruntime-react-native — API у них общий. Всё, что намерено на стенде, относится к коду, который едет в магазин, а не к его отдельной реализации на другом языке.
Несколько вещей, которые пришлось выяснять самим:
-
модели ждут каналы в порядке BGR, а не RGB;
-
размера входа детектора 960 из конфига на визитках мало, взяли 1600;
-
растяжение контраста перед детектором дало 11 процентных пунктов качества;
-
unclipдля рамок строк пришлось написать безpyclipper; -
вертикальные строки нужно поворачивать на 90° до распознавателя.
OCR отдаёт строки, а не поля
Это главное, что я недооценил. И ML Kit, и Vision, и PaddleOCR возвращают одно и то же: список строк с рамками и уверенностью. Что из этого фамилия, а что должность, не говорит ни один.
Поля визитки делятся на три уровня сложности:
|
Уровень |
Поля |
Чем берётся |
|---|---|---|
|
Просто |
телефон, почта, сайт |
регулярным выражением |
|
Средне |
компания с «ООО», адрес |
маркерами и словарями |
|
Сложно |
ФИО, должность, компания без маркера |
здесь и живёт вся задача |
Разбор строк в поля — обычный код на правилах, опирающихся на геометрию карточки (кегль, положение, соседство строк), словари имён, фамилий и должностей и типографику. Правила держатся на вёрстке, общей для визиток на любом языке, поэтому английские карточки отдельной ветки не потребовали.
На картинке выше видна типичная ловушка. Адрес распознаватель прочитал как «Roссия»: красным отмечены латинские буквы внутри кириллического слова. Для модели «R» и «Р», «o» и «о» неразличимы, а словарь такое слово уже не узнаёт. Отсюда отдельный шаг, который сводит смешанные алфавиты до разбора. Алфавит слова задают однозначные буквы: «Ж» бывает только кириллической, «S» только латинской. Слово, где однозначных букв нет, не трогается вовсе.
На этой же визитке нашлись ещё два дефекта чтения:
-
«ООО «Ромашка»» прочиталось как «ОООРомашка»» — открывающая кавычка съедена вместе с пробелом после «ООО»;
-
группы цифр телефона слиплись: «123 45» превратилось в «12345».
Ни тот ни другой до формы не доехали: в ней «ООО Ромашка» и «+7 900 123 45 67».
А капс-правило на снимке как раз сработало: «ПЕТРОВ» напечатан заглавными среди слов с обычной заглавной, поэтому слово ушло в фамилию, а «Иван» — в имя. Правило появилось не на ровном месте: на тестовом наборе встречались карточки, где первое слово забирало фамилию себе.
Ещё одна находка — сайт на карточке как подсказка к названию компании. Когда логотип нечитаем, домен помогает найти строку с названием, напечатанным мелко. Побуквенно кириллическое название с латинским доменом не совпадает никогда, поэтому оба сворачиваются к общим классам звуков. Строку берём только при точном совпадении. Мягкое совпадение выбрало «TOLOT» — нераспознанный логотип, отличающийся от домена одной буквой, — вместо правильно прочитанного адреса сайта.
Как мерить без обучающей выборки
Открытых наборов фотографий визиток практически нет, русских нет вовсе: искали на Kaggle, Hugging Face, Roboflow, Zenodo и Mendeley. Реестр юрлиц тоже не помогает: на визитке печатают бренд, а не наименование, юридическая форма есть примерно на каждой пятой карточке. Поэтому мерим на собственном тестовом наборе из 193 снимков.
Каждая карточка прогоняется дважды:
-
прогон А — живой OCR, то, что увидит пользователь;
-
прогон Б — вручную выправленный текст тех же строк.
Разница между ними показывает, кто виноват: распознавание или раскладка. На первом замере раскладка на безошибочном тексте собирала ядро контакта (имя, фамилия, компания, телефон или почта) на 88 % карточек, а на живом OCR — на 44 %. Узкое место оказалось в распознавании, а не в правилах. Как выяснилось позже, ровно до тех пор, пока не пришли незнакомые карточки.
Самый неприятный урок: правила раскладки обобщаются слабо. На 47 снимках с камеры, по которым писались правила, ядро на живом OCR дошло до 53 %. На 59 новых визитках, которых правила не видели, — 25 %. Тогда мы ввели критерий, который ближе к тому, что нужно человеку: имя верно, компания узнаваема, есть телефон. По нему на незнакомых визитках 56 %, после классификатора 63 %.
По полям на данных подгонки картина такая: телефон 100 %, почта 96 %, имя и фамилия 94 %, компания 57 %. Почти всё, что теряется, — название компании, набранное декоративным шрифтом логотипа.
Классификатор строк, обученный на нашем наборе, правила не заменил, и это проверено кросс-валидацией: 60 % по компании без него против 28–54 % с ним. Признаки «доля заглавных» и «слово организации» тянут его к длинной юридической строке, а в контакт люди пишут бренд. В итоге он работает узко: подсказывает строку, только если после всех правил компания осталась пустой. Это +2 пункта и ни одной потери.
Прежде чем строить дорогую меру, мы раскладываем провал по причинам. Для компании была идея отдельной модели, читающей логотип по пикселям. Замер по всем 193 карточкам показал: название детектор находит на 93 % карточек и читает буква в букву на 79 % из них. Провал сидит в выборе строки, а не в чтении логотипа. Модель адресовала бы 8 % карточек, и мы её не делали.
Мерить до и после каждой правки, на всём наборе, и сравнивать выгрузки по карточкам, а не сводную цифру. Правка, которая чинит три карточки и ломает три другие, в сводной цифре невидима. Таких регрессов было много, и глазами не был виден ни один.
Скорость: 7,8 → 3 секунды
Первая рабочая сборка на телефоне среднего класса (Xiaomi, два больших ядра Cortex-A710 и шесть малых A510) тратила от нажатия затвора до готового контакта около 7,8 с. Из них 5,2 с занимал OCR 15 строк. Что помогло:
Число потоков ONNX Runtime. По умолчанию сессия заводит поток на каждое ядро, а ядра у телефона неравные: работа, разложенная на восемь, ждёт медленные. Умолчание давало 6,3–13,1 с на кадр с разбросом в секунды, два потока — 2,6–4,1 с, текст тот же. Четыре потока так же медленны, как умолчание. Одна строка intraOpNumThreads: 2 — ускорение примерно в 3,7 раза, и разброс схлопнулся.
XNNPACK не включать. executionProviders: ['xnnpack'] ронял процесс SIGSEGV через две секунды после создания сессии.
Перечитывание перевёрнутых строк — только когда карточка не прочиталась. Строку с низкой уверенностью мы перечитывали повёрнутой на 180°, на случай визитки вверх ногами. На снимке с телефона сомнительных строк оказалось много и у нормально снятой карточки: 40–59 вызовов распознавателя на 14–21 строку. Теперь второй проход идёт только для карточки, которая не прочиталась: 7,1 → 4,7 с. Подвох: цифры вверх ногами читаются как цифры, поэтому один найденный телефон ориентацию не доказывает.
Замыкание в пиксельном цикле стоило в 56 раз дороже самой работы. Функция выборки пикселя объявляла внутри себя стрелочную функцию, то есть создавала замыкание на каждый вызов. На один ресайз таких вызовов 5,8 млн. На ноутбуке это незаметно, а на телефоне JS исполняет Hermes, у которого нет оптимизирующего JIT. Ресайз снимка 4000×3000: 5084 → 91 мс. Правило общее: в горячих циклах над растром — ни стрелок, ни .map.
Тут же грабли второго порядка. a*(1-f) + b*f и арифметически равное a + (b-a)*f расходятся в последнем бите, а результат ложится в Uint8Array усечением: 231,999999 вместо 232 даёт соседний уровень яркости. Поймал это тест, сверяющий пиксели с прямолинейной реализацией. Иначе качество распознавания сдвинулось бы на всём наборе молча.
Обрезка по карточке. Быстрый проход детектора на 640 px находит скопление строк, и полноценный детектор работает уже по вырезанной карточке: 4,7–5,8 → 2,9–3,6 с.
С обрезкой связана самая неочевидная находка. Кроп меняет ответ детектора целиком, а не только у краёв. В детекторе 20 узлов GlobalAveragePool (блоки squeeze-and-excitation), и веса каналов считаются по среднему всего входа. Вырезанная карточка даёт рамки строк, сдвинутые на 1–4 px даже далеко от края, и распознаватель читает строку на символ иначе. Поле теряется без единой срезанной строки. Поэтому режем только там, где это выгодно: если карточка занимает больше 55 % кадра, кроп не делаем.
Что не взяли:
-
пакетное распознавание строк (батч 4) быстрее на 8 %, но на 10 карточках потеряло 13 полей: дополнение строки нулями до соседа по пачке меняет выход модели заметнее, чем ожидалось;
-
разрешение выше 1600 px ничего не даёт: полный кадр 4000 px — 48 % ядра, копия 1600 — 46 %, копия 2400 — 45 %. Зато декодирование JPEG дорожает с 265 мс до 2 с, а растр в памяти — с 6 до 36 МБ.
Память и размер
Арена памяти ONNX Runtime у детектора держала 550 МБ до конца процесса. Вход 1600 даёт большие промежуточные тензоры: на первом снимке арена вырастает под пик и назад не отдаёт. Утечкой это не выглядит — от снимка к снимку память не растёт, поэтому ни по времени, ни по серии снимков ничего не видно. Выключили арену только у детектора: приложение в простое похудело примерно с 800 до 410–470 МБ PSS ценой 0,1 с на снимок. Серия из 11 снимков подряд идёт без замедления.
Попутно выяснилось, что троттлинг не виден в thermal status: тот оставался нулевым, пока большие ядра уже сидели на 1,8–2,2 ГГц вместо 2,8. Нагрев надо смотреть по частотам.
Размер. До распознавания APK для arm64 весил 27,1 МБ, с ним — 39,8 МБ. Собственная урезанная сборка ONNX Runtime только с нужными операторами вернула 32,5 МБ. Проверяли не «поля те же», а побайтовое совпадение выхода конвейера на 205 кадрах.
ONNX Runtime звонит в Microsoft
Отдельная история, которую стоит знать всем, кто тащит ORT в мобильное приложение. В сетевом логе симулятора iOS сразу после распознавания мы увидели два POST https://mobile.events.data.microsoft.com/OneCollector/1.0. Телеметрия в ORT включена по умолчанию, это прямо написано в onnxruntime_c_api.h. Для функции, весь смысл которой — «ни одного сетевого запроса», это неприемлемо.
Приехала она молча: пакет по умолчанию тянет нативную сборку latest.integration, и новая версия принесла телеметрию с собой. Что сделали:
-
патч модуля:
setenv("ORT_DISABLE_TELEMETRY", "1")до созданияOrt::EnvиDisableTelemetryEvents()после; -
закрепили нативную версию ORT во всех местах, где она указана;
-
урезанную сборку для Android собрали с
--no_telemetry.
Проверять только сетевым логом: в отладочном логе отправка не видна.
Урок про синтетику
Последнее, о чём хочу рассказать, — ошибка процесса. Мы проверяли, как распознавание переносит наклон карточки. Синтетические повёрнутые копии снимков теряли поля, и по ним мы написали выпрямление карточки перед распознаванием, стенд и два замера на телефоне. Потом сделали десять живых снимков затвором — это минут пятнадцать. Выпрямление на двух кадрах из трёх убирало имя, а без него наклон почти ничего не терял. Синтетика врала своими артефактами: шов фона, срезанные края, пересэмплирование.
Код откатили. Вывод простой: дешёвую живую проверку ставить до кода, а синтетикой только уточнять.
Про ИИ
Почти весь код и замеры в этой работе сделал ИИ-агент (Claude Code), а моя роль была архитектурной: ограничения, постановка, решения «берём / не берём» и приёмка по цифрам. Без агента три недели на такой объём замеров были бы нереальны. Но и обратное верно: выпрямление из предыдущего раздела агент написал уверенно и аккуратно, и опровергли его только живые снимки. Дисциплина «замер до и после на всём наборе» нужна агенту ещё больше, чем человеку.
Итого
-
Фото визитки не покидает телефон; это решение определило всё остальное.
-
На Android кириллического OCR из коробки нет; PaddleOCR v5 через ONNX Runtime работает, с урезанной сборкой это +5 МБ к загрузке.
-
OCR — не главная задача. Главная — разложить строки по полям, и правила на незнакомых карточках обобщаются хуже, чем кажется на своих.
-
7,8 → 2,9–3,6 с на снимок дали не модели, а потоки, лишние проходы, замыкания и обрезка.
-
Мерить на всём наборе и сравнивать по карточкам.
Вопросы по конвейеру и замерам — в комментариях, отвечу.
Автор: melenchenko


