- BrainTools - https://www.braintools.ru -

Всем привет! На связи Владимир Бойко и Александр Лахонин, мы занимаемся продуктом «Умная камера» в Центре технологий искусственного интеллекта [1] Т-Банка. В статье рассказываем, как в суперсжатые сроки реализовали распознавание номеров телефонов on-device на iOS. Результаты работы мы представили 40 тысячам гостей на стенде Т-Банка нашего продукта на ИТ-Пикнике 2024 — ежегодном фестивале для айтишников.
Мы расскажем о технических достижениях и вызовах, с которыми столкнулись, поделимся решениями, которые разработали специально для мероприятия, а еще теми, что уже встроены в наши приложения и успешно используются
В июле нам нужно было сделать стенд Умной камеры в зоне Т-Банка, где компания представляла свои продукты и технологии ИИ на ИТ-Пикнике. У нас было три недели и пять человек в команде, чтобы придумать и воплотить свою идею. Мы постарались сделать приложение, которое было бы интересным и взрослым, и детям.
Мы решили показать гостям, что компьютерное зрение [2] работает точнее и быстрее, чем человек. Умная камера от Т-Банка умеет определять разные объекты [3] — от рукописных номеров телефонов до кошек и собак. Специально для стенда мы придумали интерактивную игру из двух раундов, в которой пользователи соревновались между собой и с Умной камерой. Суть игры — распознать номера телефонов за меньшее время.
В первом раунде пользователям нужно было ввести руками номер телефона с экрана телевизора перед ними. Во втором — отсканировать Умной камерой напечатанный номер телефона с того же самого телевизора. Побеждал тот пользователь, у которого время по итогам двух раундов было меньше. В конце отображали лидерборд с топ-3 лучшими результатами за все время.
Написали с нуля два приложения: клиентское и серверное. Хотели сделать игру максимально автономной. По опыту [4] прошлого ИТ-Пикника из-за большого количества посетителей на месте могли быть серьезные проблемы с доступом в интернет. Это повлияло на выбор технологий во время реализации игры.
Клиентское приложение состояло из шести экранов:
ввод никнейма;
ожидание второго игрока;
ввод номера телефона;
промежуточные результаты;
камера;
финальный экран с результатами и местом.
Серверное приложение — основной центр управления. С него управляли двумя клиентами, и на нем же была логика [5] сетевого взаимодействия: синхронизация между раундами, таймеры, подсчет результатов и записи в БД, валидации никнеймов. Вторая часть приложения транслировалась на экран телевизора — там были инструкции, лидерборд, таймеры и сами номера.
Нам не нужно было поддерживать большой парк устройств и можно было использовать самую актуальную iOS, мы могли применять самые современные технологии и подходы. Поэтому решили использовать:
SwiftUI + Combine для верстки.
SwiftData для хранения данных.
Swift Concurrency для решения задач многопоточности.
Связку TensorFlowLite + Accelerate + Metal для определения номеров. В качестве альтернативы и для замеров качества нативных решений — Vision.
Multipeer Connectivity для реализации сетевого соединения. Позже мы перешли на использование WebSocket.
Оба приложения написаны на SwiftUI, потому что на нем простые экраны создавать быстрее и проще. У нас не было специфичных анимаций или сильно кастомных элементов. Combine использовали, потому что он хорошо интегрируется со SwiftUI.
Серверное приложение писалось под macOS, и там тоже не возникло проблем с версткой. Плюс ко всему, на macOS нет UIKit, поэтому, используя SwiftUI, мы решали проблему переиспользуемости некоторых UI компонентов на обеих платформах.
В клиентском приложении мы предусмотрели дебаг-меню, которое упростило тестирование и управление. В этом меню можно изучать логи событий, открыть нужный экран, протестировать ML-модели и изменить настройки камеры.
В серверном приложении мы тоже предусмотрели дебаг-меню, которое обогатили логами событий и значениями базы данных. На вкладке «Управление клиентами» в реальном времени можно было посмотреть состояние подключенных устройств и открыть любой экран, чтобы протестировать его или вообще перезапустить весь сценарий.
Добавили возможность ручного управления обоими клиентами — в каких-то экстренных случаях мы вешали на весь экран заставку о проведении работ и уходили на маленький перерыв. Но таких перерывов было очень-очень мало.
Хранение данных. Нам необходимо было логировать результаты пользователей во время соревнования: время камеры, время человека, общее и никнеймы. Для этого написали локальную БД на серверном приложении с помощью SwiftData, поскольку, по документации и примерам от Apple, с ней намного проще работать, чем с той же CoreData. Все построено на макросах и заводится с пол-оборота. Так и было.
Соединение клиентов с сервером. Для реализации соединения между клиентом и сервером с самого начала решили использовать Multipeer Connectivity. Если судить по описанию, технология отличная и идеально подходит для наших нужд. Она не требует локальной сети, все работает из коробки и обеспечивает простое подключение.
Multipeer Connectivity позволяет соединяться двум и более устройствам, которые не подключены к одной локальной сети (нет явного IP-адреса), и для поиска устройств используется Bluetooth или Wi-Fi.
В процессе тестов выяснилось, что Multipeer Connectivity недостаточно надежна в плане поддержания соединения. В любой момент соединение могло пропасть: клиент отображал сервер в сети, в то время как сервер не видел клиента. Из-за сжатых сроков у нас не получилось уделить достаточно времени отладке, а еще мы нашли подтверждение тому, что эта технология нам не подойдет. [7] Тогда мы приняли решение пойти в сторону проверенного и известного решения — WebSocket.

Нам понадобился маршрутизатор для раздачи локальной сети, он никуда не был подключен, кроме розетки. На маршрутизаторе мы установили статический IP-адрес для сервера и жестко прописали его в клиентском приложении.
Все работало как нужно, никаких сбоев не было. Практически…
Во время тестов выяснилось, что большинство пользователей по привычке нажимали кнопку блокировки телефона и это автоматически разрывало соединение. Решение заключалось в автоматическом восстановлении соединения с сервером методом AppDelegate. И вот тогда все заработало как часы.
Настройка распознавания номеров. Первое, о чем мы подумали: есть крутой нативный фреймворк от Apple — Vision, с хорошей документацией, простой интеграцией и понятными возможностями. Мы очень легко и просто завели первую версию, которая, на первый взгляд, быстро и точно все сканировала.
Позже столкнулись с тем, что, если рядом с номером будет какой-нибудь текст, мы его тоже распознаем. Вроде бы не такая большая проблема — добавим регулярку проверяющую, что в строке 11 цифр. А если в номере будет «+» и «(“”)», такой номер не пройдет.
Чуть усложнили регулярное выражение. И вроде бы теперь точно все идеально, но периодически мы стали замечать, что некоторые номера, особенно рукописные, не проходят регулярные выражения.
Начали выяснять и поняли, что Vision не понимает контекст и что буква о очень похожа на 0, а цифра 1 — на i и l. Нам удалось найти все или почти все кейсы схожих символов и цифр, и получился метод, который уже кратно повышал точность, но разные интересные кейсы все равно продолжали и продолжали возникать.
Мы попытались заменить возможные неверные интерпретации Vision-символов на цифры. Предполагаем, что максимальное число замен будет три. Если их будет больше, вероятно, это не номер, который нас интересует.
Подробнее про разницу между fast и accurate можно почитать на Recognizing Text in Images. [8]
Функция по замене символов в выходном результате:
extension Character {
func getSimilarCharacterIfNotIn(allowedChars: String) -> Character? {
let conversionTable = [
"s": "5",
"S": "5",
"o": "0",
"Q": "0",
"O": "0",
"i": "1",
"l": "1",
"I": "1",
"B": "8",
"в": "8",
"b": "8",
"з": "3",
"о": "0",
"О": "0",
":": "8"
]
let maxSubstitutions = 3
var current = String(self)
var counter = 0
while !allowedChars.contains(current) && counter < maxSubstitutions {
if let altChar = conversionTable[current] {
current = altChar
counter += 1
} else {
break
}
}
return current.first
}
}
Мы не на шутку задумались: а ведь у нас есть своя модель, которая в проде сканирует тысячи номеров телефонов в день и работает очень хорошо. Но есть маленькое но: эта модель развернута на бекенде и нам нужно перенести ее в наше приложение.
Приключение на 15 минут, подумали мы…
Работа с нашими моделями. Мы решили сделать ставку на собственные модели. Они работали с распознаванием данных быстрее и точнее. Этот подход позволил нам избежать большинства проблем, связанных с избыточной информацией и медленной обработкой, которые возникали при использовании Vision.
Наши коллеги из Computer Vision сделали специальные модели под мобильные устройства, натренированные на поиск телефонов на изображении. В итоге у нас получился пайплайн из трех моделей плюс наша логика обработки изображений в переходных состояниях.
Кроппер находит на начальном изображении интересующий нас прямоугольник. Их может быть несколько, если на изображении присутствуют несколько областей с текстом.
На выходе получаем координаты для вырезки области.
После кроппера изображение, вырезанное перспективно по выданным имкоординатам, отправлялось в сегментер, который выдавал маску интересующего нас текста.
Потом изображение снова обрезалось и отправлялось в OCR, которая отдавала уже текст
Полученный пайплайн получился гибким. Для поиска других артефактов, будь то банковские карты или что-то другое, достаточно будет заменить или дообучить сегментер под поиск чего-то, кроме телефонов.
Для запуска моделей нам потребовался TensorFlowLite. Поскольку его интерфейсы принимают на вход Data, необходимо преобразовать из CIImage в Data с учетом желаемых размеров, количества битов для представления канала на один пиксель, количества битов для представления самого пикселя и дополнительных флагов. Для этих целей мы воспользовались Accelerate [9] и его типами — он позволял проводить оптимизированные высокопроизводительные векторные вычисления. Ниже приведена часть функции по преобразованию типов с его помощью:
// Используем vImage_CGImageFormat для формата исходного изображения
var format = vImage_CGImageFormat(bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: nil,
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
version: 0,
decode: nil,
renderingIntent: .defaultIntent)
// Инициализируем vImage_Buffer для исходного изображения
var sourceBuffer = vImage_Buffer()
defer {
// Не забываем освободить память
sourceBuffer.data?.deallocate()
}
// Инициализируем буффер с помошью CGImage, проверяя на ошибки
var error = vImageBuffer_InitWithCGImage(&sourceBuffer, &format, nil, cgImage, vImage_Flags(kvImageNoFlags))
guard error == kvImageNoError else {
print("Error initializing vImage buffer: (error)")
return nil
}
// Определяем количество байтов на пиксель и считаем размер строки для буффера
let bytesPerPixel = 4 // Формат ARGB (1 байт для каждого канала
let destBytesPerRow = destWidth * bytesPerPixel
// Выделяем память под буффер
let destData = UnsafeMutablePointer<UInt8>.allocate(capacity: destHeight * destBytesPerRow)
defer {
// Не забываем освободить память
destData.deallocate()
}
// Создаем буффер с аллоцированными данными
var destBuffer = vImage_Buffer(data: destData,
height: vImagePixelCount(destHeight),
width: vImagePixelCount(destWidth),
rowBytes: destBytesPerRow)
// Масштабируем исходное изображение под размеры конечного буффера, проверяем на ошибки
error = vImageScale_ARGB8888(&sourceBuffer, &destBuffer, nil, vImage_Flags(kvImageHighQualityResampling))
guard error == kvImageNoError else {
print("Error scaling image: (error)")
return nil
}
// Выделяем память под RGB data (3 канала на пиксель)
let rgbData = UnsafeMutablePointer<UInt8>.allocate(capacity: destHeight * destWidth * 3)
defer {
// Не забываем освободить память
rgbData.deallocate()
}
// Конвертируем ARGB-данные в RGB-формат, копируя только RGB-каналы
for y in 0..<destHeight {
for x in 0..<destWidth {
let sourceIndex = y * destBytesPerRow + x * 4 // Каждый пиксель равен 4 байтам(ARGB)— исходное изображение
let destIndex = (y * destWidth + x) * 3 // Каждый пиксель равен 3 байтам(RGB)— конечное изображение
rgbData[destIndex] = destData[sourceIndex] // Красный канал
rgbData[destIndex + 1] = destData[sourceIndex + 1] // Зеленый канал
rgbData[destIndex + 2] = destData[sourceIndex + 2] // Синий канал
}
}
// Создаем объект Data из rgbData для дальнейшего процессинга
let data = Data(bytes: rgbData, count: destHeight * destWidth * 3)
Изначальное изображение часто не имеет ровного горизонтального текста, что требует применения перспективной трансформации — стандартной практики в области компьютерного зрения.
Нам требовалась перспективная трансформация для получения горизонтально выровненного изображения с текстом. Сначала мы попробовали perspectiveTransform [10], но он выдал не тот результат, который нам нужен.
Тогда мы применили perspectiveCorrection [11] и получили нужное преобразование, с которым могли идти дальше.
После отработки алгоритмов и поиска связанных компонентов мы заново обрезаем изображение от сегментера и отправляем полученный результат в OCR для чтения текста.
Для выполнения расчетов на GPU мы использовали Metal, чтобы ускорить процесс. Это позволило оптимизировать алгоритм поиска связанных компонентов. Подобная задача типичная в компьютерном зрении, и обычно для ее решения используют фреймворки наподобие OpenCV.
Мы предпочли реализовать собственное решение. Написали шейдер с двумя проходами для вычислений, что существенно улучшило производительность распознавания текста.
Шейдер — это отдельная программа, которая исполняется на GPU. Есть несколько типов разных шейдеров в зависимости от предназначения. Наиболее часто они используются в 3D-графике, в нашем случае мы использовали специальный тип шейдера (compute), который позволяет обрабатывать каждый пиксель изображения параллельно.
Оставлю ссылку для тех, кто хочет подробнее прочитать про двойной подход для поиска связанных компонентов. [12]
Первое прохождение в шейдере:
kernel void firstPass(device uint *labels [[buffer(0)]],
device float *image [[buffer(1)]],
constant uint &width [[buffer(2)]],
constant uint &height [[buffer(3)]],
device atomic_uint &pixelsCount [[buffer(4)]],
device uint *labelsRes [[buffer(5)]],
uint2 gid [[thread_position_in_grid]]) {
uint x = gid.x;
uint y = gid.y;
if (x >= width || y >= height) return;
uint index = y * width + x;
uint newIndex;
if (index % 4 == 1) {
newIndex = index / 4;
} else {
return;
}
uint size2 = 256 * 256;
uint newIndex2;
if (index % 256 == 0) {
newIndex2 = newIndex / 256;
} else {
newIndex2 = (newIndex % 256) * (size2 / 256) + newIndex / 256;
}
if (image[index] < 0.5) {
labels[newIndex2] = 0;
return;
}
uint pixelIndex = atomic_fetch_add_explicit(&pixelsCount, 1, memory_order_relaxed);
labelsRes[pixelIndex] = newIndex2 + 1;
labels[newIndex2] = newIndex2 + 1;
}
Вот так выглядела работа написанного нами Connected-component labling Kernel — сущность для вызова shader и выноса работы на GPU:
func run(image: inout [Float32]) ->[Int: LabelInfo] {
// Определяем размеры для входного изображения и полного изображения
let width = 256
let height = 256
var widthFull = 256 * 2
var heightFull = 256 * 2
// Инициализируем массивы для хранения данных меток и результатов.
var labels = [UInt32](repeating: 0, count: width * height) // Метки для каждого пикселя
var labelsRes = [UInt32](repeating: 0, count: width * height) // Результирующие метки после обработки
var pixelsCount: UInt32 = 0 // Счетчик ненулевых пикселей
// Создаем буферы для обработки на GPU с использованием Metal
let imageBuffer = stack.device.makeBuffer(bytes: &image, length: image.count * MemoryLayout<Float32>.stride, options: .storageModeShared)
let labelsBuffer = stack.device.makeBuffer(bytes: &labels, length: labels.count * MemoryLayout<UInt32>.stride, options: .storageModeShared)
let labelsResBuffer = stack.device.makeBuffer(bytes: &labelsRes, length: labelsRes.count * MemoryLayout<UInt32>.stride, options: .storageModeShared)
let pixelsCountBuffer = stack.device.makeBuffer(bytes: &pixelsCount, length: MemoryLayout<UInt32>.stride, options: .storageModeShared)
// Буферы для хранения размеров изображения
let widthBuffer = stack.device.makeBuffer(bytes: &widthFull, length: MemoryLayout<UInt32>.stride, options: .storageModeShared)
let heightBuffer = stack.device.makeBuffer(bytes: &heightFull, length: MemoryLayout<UInt32>.stride, options: .storageModeShared)
// Создаем commandBuffer и commandEncoder вычислений для выполнения задач на GPU
let commandBuffer = stack.commandQueue.makeCommandBuffer()!
let commandEncoder = commandBuffer.makeComputeCommandEncoder()!
// Устанавливаем состояние вычислительного пайплайна и связываем буферы с кодировщиком
commandEncoder.setComputePipelineState(firstPassPipelineState)
commandEncoder.setBuffer(labelsBuffer, offset: 0, index: 0) // Привязываем буфер меток
commandEncoder.setBuffer(imageBuffer, offset: 0, index: 1) // Привязываем буфер изображения
commandEncoder.setBuffer(widthBuffer, offset: 0, index: 2) // Привязываем буфер ширины
commandEncoder.setBuffer(heightBuffer, offset: 0, index: 3) // Привязываем буфер высоты
commandEncoder.setBuffer(pixelsCountBuffer, offset: 0, index: 4) // Привязываем буфер количества пикселей
commandEncoder.setBuffer(labelsResBuffer, offset: 0, index: 5) // Привязываем буфер результирующих меток
// Определяем размер сетки и размер группы потоков для отправки вычислительных задач
let gridSize = MTLSize(width: widthFull, height: heightFull, depth: 1)
let threadGroupSize = MTLSize(width: 8, height: 8, depth: 1)
// Отправляем потоки на выполнение
commandEncoder.dispatchThreads(gridSize, threadsPerThreadgroup: threadGroupSize)
// Завершаем кодирование команд и отправляем командный буфер для выполнения
commandEncoder.endEncoding()
commandBuffer.commit()
// Ждем завершения всех команд перед продолжением
commandBuffer.waitUntilCompleted()
// Читаем результаты из буферов GPU в локальные переменные
let resultPointer = labelsBuffer?.contents().bindMemory(to: UInt32.self, capacity: width * height)
let result = Array(UnsafeBufferPointer(start: resultPointer, count: width * height))
let pixelsCountPointer = pixelsCountBuffer?.contents().bindMemory(to: UInt32.self, capacity: 1)
let pixelsCountResult = pixelsCountPointer!.pointee
let labelsGPUResultPointer = labelsResBuffer?.contents().bindMemory(to: UInt32.self, capacity: Int(pixelsCountResult))
let labelsGPUResult = Array(UnsafeBufferPointer(start: labelsGPUResultPointer, count: Int(pixelsCountResult)))
// Фильтруем нулевые значения и создаем массив помеченных пикселей
var array = labelsGPUResult.sorted(by: <).compactMap {
$0 == 0 ? nil : LabeledPixel(value: Int($0))
}
// Создаем словарь для хранения информации о метках
var labelsDict: [Int: LabelInfo] = [:]
// Выполняем проход по CPU для дальнейшей обработки помеченных пикселей и заполнения словаря
cpuPass(la: &array, labelsDict: &labelsDict)
return labelsDict // Возвращаем словарь с информацией о метках
}
Иногда возникает ситуация, когда координаты, полученные от кропера, оказываются отрицательными. Это указывает на то, что изображение расположено близко к границе исходного изображения, например, в левом верхнем углу. В таких случаях необходимо дополнить изображение черными областями на величину, равную отрицательной координате плюс один пиксель. Это позволяет системе корректно работать и обеспечивать стабильный результат.
Результаты получились очень крутыми. Мы логировали ошибки [13], и в итоге модель ни разу не ошиблась, в то время как люди ошибались часто. Если говорить про время распознавания, наша Умная камера превзошла все наши ожидания: в среднем она оказалась быстрее человека в 7,5 раза!
|
Камера |
Человек |
|
|
Среднее время |
0,84 |
6,26 |
|
Самое длительное время |
5,73 |
73,09 |
|
Самое быстрое время |
0,0069 |
2,59 |
Отдельного внимания [14] заслуживает проблема нагрева телефонов. При превышении определенного температурного порога система перестает выделять необходимые нам ресурсы. В результате вместо изображения с камеры можно получить черный экран или системное предупреждение. [15] Из-за моделей onDevice и постоянной работы устройства будут перегреваться, поэтому мы добавили мониторинг температуры.
Устройства пришлось заменить около четырех раз из-за уведомлений о перегреве за первый час работы стенда. Это стало неожиданностью, поскольку во время тестов мы с таким не сталкивались. Мы предположили, что проблема в «антикражных датчиках» — магните, прикрепляемом к задней части телефона, и зарядном кабеле. Кажется, что проблема была как раз в постоянной зарядке телефона, из-за которой он нагревался.
В итоге мы решили отказаться от этой связки с датчиками. Это оказалось верным решением, поскольку устройства практически перестали перегреваться. Одна пара телефонов проработала без перерыва почти четыре часа.

За время работы стенда нас посетило около 800 участников. Записей в БД насчитали 916, некоторые участники стояли в очереди несколько раз и вводили разные никнеймы.
CSAT (customer satisfaction score) по итогам опроса получился 4,7 из 5 — это очень хорошие показатели, которыми мы довольны.
Мы сделали такие выводы:
SwiftUI позволяет тратить меньше времени на разработку простых экранов и переиспользовать их на MacOS.
SwiftData — простой, приятный и удобный фреймворк для MVP или небольших проектов. Но требует глубокой проработки перед использованием на больших проектах.
Vision — неплохой фреймворк компьютерного зрения от Apple, но требует много доработок, если вы хотите заточить его под определенную задачу. И если вы хотите сделать не просто неплохое, а лучшее решение, точно стоит задуматься о создании и использовании более узконаправленного решения.
On-Device ML и вычислениям точно быть, и мы в в Центре искусственного интеллекта Т-Банка (AI-Центре) активно развиваем это направление. Но всегда стоит осторожно взвешивать все плюсы и минусы On-Device- и Server-Side-подходов. Когда на стенде использовались мощные устройства, у нас точно могли быть проблемы с интернет-соединением, и нам важно, чтобы все быстро работало. У нас не было ограничений в размере приложения и так далее, нам точно имело смысл делать решение on-device. Наше решение получилось быстрее и точнее Vision, и мы будем дальше использовать эти наработки для создания еще более качественных продуктов.
Автор: vaverkax
Источник [16]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/11353
URLs in this post:
[1] интеллекта: http://www.braintools.ru/article/7605
[2] зрение: http://www.braintools.ru/article/6238
[3] Умная камера от Т-Банка умеет определять разные объекты: https://www.tbank.ru/about/news/05122023-tinkoff-launched-the-first-smart-financial-camera/
[4] опыту: http://www.braintools.ru/article/6952
[5] логика: http://www.braintools.ru/article/7640
[6] docs.developer.apple.com: https://docs.developer.apple.com/documentation/multipeerconnectivity
[7] нашли подтверждение тому, что эта технология нам не подойдет.: https://habr.com/ru/companies/dataart/articles/275627/
[8] на Recognizing Text in Images.: https://developer.apple.com/documentation/vision/recognizing-text-in-images
[9] Accelerate: https://developer.apple.com/documentation/accelerate
[10] perspectiveTransform: https://developer.apple.com/documentation/coreimage/cifilter/3228382-perspectivetransform
[11] perspectiveCorrection: https://developer.apple.com/documentation/coreimage/cifilter/3228380-perspectivecorrection
[12] прочитать про двойной подход для поиска связанных компонентов.: https://www.ocf.berkeley.edu/~fricke/projects/hoshenkopelman/hoshenkopelman.html
[13] ошибки: http://www.braintools.ru/article/4192
[14] внимания: http://www.braintools.ru/article/7595
[15] или системное предупреждение.: https://help.apple.com/xcode/mac/current/#/dev308429d42
[16] Источник: https://habr.com/ru/companies/tbank/articles/874868/?utm_source=habrahabr&utm_medium=rss&utm_campaign=874868
Нажмите здесь для печати.