
Привет, Хабр! Я Евгений Шувагин, последний год работаю над компонентом Face-Shot в Т-Банке.
Представим ситуацию: клиент хочет перевести большую сумму, используя мобильное приложение. Внутренние системы видят подозрительную активность и запрашивают проверку по селфи, человек наводит камеру на лицо — получает отказ. А все из-за того, что на бэкенде была получена размытая фотография потолка с кусочком лица пользователя, по которому никак не понять, действительно ли это владелец счета хочет провести операцию. Клиент звонит в поддержку, злится. Банк теряет доверие. Именно такие сценарии заставили нас серьезно заняться качеством записываемого фото.
Что такое Face-Shot
Face-shot — внутренняя Android-библиотека, которая анализирует 30 кадров в секунду при помощи различных ML в попытках найти лицо, смотрящее в камеру, и передает найденное лицо на бэкенд для подтверждения рисковых операций. И тут встречаемся с требованиями:
кадр должен быть четким, с правильной экспозицией и минимальным шумом;
лицо в центре кадра, смотреть в сторону камеры, с открытыми глазами, без очков и шарфов;
все это должно работать стабильно на разных устройствах.
Эта статья — моя попытка поделиться интересными находками и проблемами, которые встречались на моем пути. Но для начала вспомним базовые способы работать с камерой — актуальны ли они сегодня.
Эволюция и особенности API
В Android есть четыре основных способа работы с камерой. Если проводить аналогию с реальным миром, то представляем, что нам нужно сделать фотографию, но под рукой у нас четыре разных способа:
Camera API 1 — фотоаппарат-мыльница. Нажал кнопку и получил результат. Самый старый способ, где нет возможности настроить результат.
Camera API 2 — профессиональный фотоаппарат в ручном режиме. Перед каждым кадром нужно самому выставлять нужные настройки, но их так много, что легко запутаться.
Сamera NDK — тоже профессиональная кинокамера, где еще больше кнопок и нет автоматики. Тут мы контролируем все, что дает больше гибкости, но и больше неожиданных проблем.
CameraX — мобильный телефон: мы просто жмем на кнопку, а система сама подбирает нужные настройки в зависимости от телефона, при этом всегда дает возможность внести нужные корректировки.

Железо: почему на разных телефонах камера работает по-разному
Я часто замечал, что одно и то же приложение, которое работает с камерой, на дорогих телефонах имеет больше возможностей, чем на бюджетных. И дело в том, что внутри Android есть «переводчик» между системой и камерой — он называется HAL (Hardware Abstraction Layer). Этот переводчик на каждом устройстве свой, он рассказывает, какие возможности камеры может использовать разработчик. Чем современнее и дороже устройство — тем больше возможностей оно предоставляет разработчику.
Таких переводчиков пять:
1. LEGACY. В основном встречается на бюджетных телефонах до 2015 года. Нам прямо говорят, чтобы мы не ожидали современных возможностей и довольствовались базовыми, такими как предпросмотр, зум, выбор разрешения и обычная запись видео.
2. LIMITED. Смартфоны среднего класса до 2020. Умеют все то же, что и LEGACY, плюс открывают базовые ручные настройки, такие как контроль экспозиции и фокуса.
3. FULL. Современные смартфоны. Включает всё из уровня LIMITED, плюс становятся доступны продвинутые фишки: настройка выдержки и ISO, сохранение RAW-фото, контроль частоты кадров и все, что нужно для выполнения любой задачи.
4. LEVEL_3 («Максимальный»). Флагманские современные устройства. Тут доступны одновременный захват несжатых RAW-кадров и вывод данных с разным разрешением в несколько потоков, режимы стабилизации, шумоподавления видео, ночной режим, распознавание лиц в кадре.
5. EXTERNAL. Внешние USB-камеры, подключаемые по проводу. По возможностям как LIMITED.
Узнать уровень камеры в коде можно так:
val manager = getSystemService(Context.CAMERA_SERVICE) as CameraManager for (cameraId in manager.cameraIdList) { val characteristics = manager.getCameraCharacteristics(cameraId) val supportLevel = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL) // LEGACY, LIMITED, FULL, LEVEL_3... }
UseCase в CameraX: от абстракции к потокам данных
UseCase — описание конкретной задачи, которую мы ставим перед камерой. В старом Camera API 2 приходилось вручную открывать камеру, создавать сессии и следить, чтобы все это не сломалось при повороте экрана. CameraX меняет подход: мы просто создаем нужный нам UseCase и отдаем его библиотеке.
Главная фишка UseCase — жизненный цикл. Мы привязываем сценарий к Activity или Fragment, и CameraX сама выключает камеру, когда приложение свернуто или закрывается.
В CameraX доступны четыре основных сценария:
Preview — экран превью. Позволяет пользователю показывать то, на что направлена камера.
ImageCapture — создание фотографий.
VideoCapture — запись видео и аудио.
ImageAnalysis — дает доступ к необработанным кадрам для работы ML-моделей.
Технически каждый UseCase — это отдельный поток данных от камеры. Чтобы система работала максимально быстро, CameraX подбирает для каждого потока свой формат пикселей.
Preview и VideoCapture. Используют формат PRIV (Private). Это «черный ящик» для процессора, данные из которого летят напрямую в видеокарту (GPU). Это самый эффективный способ: процессор не тратит силы на перекладывание байтов, поэтому превью работает плавно.
ImageCapture. Использует форматы JPEG, HEIF или RAW. Они оптимизированы для сохранения финального изображения с максимальной детализацией.
ImageAnalysis. Передает кадры в форматах YUV_420_888, RGBA_8888, NV21, а идеальные форматы для ML-инструментов, чтобы на лету распознавать лица.

В версии CameraX 1.3.0 появился механизм дублирования потока. Если устройство не поддерживает необходимую комбинацию потоков, то этот механизм может разделить один поток между несколькими UseCase, например использовать общий источник данных для Preview и ImageAnalysis.

Плавность интерфейса и использование UseCaseGroup
При разработке базового функционала я заметил, что на официальных примерах для работы при запуске экрана превью долго загружается, а в некоторых моментах и вовсе мигает на 1—2 секунды черным экраном. Причина в последовательном подключении UseCase’ов. Та же ситуация возникает, когда мы привязываем новый UseCase во время работы: ведь зачем подключать UseCase для получения фото, если пользователь может и не нажать кнопку «снять фото»?
Последовательная привязка вызывает переконфигурацию сессии. В момент добавления нового UseCase камера берет паузу, чтобы найти оптимальные настройки, а для пользователя это выглядит как баг.
Чтобы избежать переконфигурации сессии, нужно использовать класс UseCaseGroup. Он объединяет все UseCase в одну группу, и CameraX настраивает камеру один раз сразу под весь набор.
val useCaseGroup = UseCaseGroup.Builder() .addUseCase(preview) .addUseCase(imageAnalysis) .addUseCase(imageCapture) .build() // Настройка произойдет один раз для всех задач — без прерывания потока cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, useCaseGroup)
Пропорции и трансформация координат
До CameraX приходилось самому трансформировать получаемый кадр из камеры для корректного отображения превью, ведь поток камеры отдает кадр в различных пропорциях и разрешении, в зависимости от камеры и выбранных настроек. В результате изображение не совпадает по пропорциям с экраном устройства.

CameraX и стандартный UseCase приходят на помощь: система сама найдет оптимальные параметры превью и применит их.

Аналогичная ситуация возникает, когда нужно отрисовать поверх превью какую-то информацию, к примеру рамочку лица для визуального подтверждения работоспособности ML-модели нахождения лица в кадре.
Например, если на анализ поступает фотография размером 300 × 300 пикселей и мы получаем координаты лица, скажем, [45, 45, 150, 150], то эти координаты указывают положение и размеры области на исходном изображении. Но, чтобы правильно отобразить эти данные на экране устройства, необходимо трансформировать координаты с учетом пропорций между изображением и экраном, чтобы лицо отображалось корректно и без искажений.

К счастью, считать все руками не нужно. В CameraX есть решение: вместо того, чтобы пересчитывать каждую координату по отдельности, мы один раз создаем правило трансформации из координат кадра в координаты превью. Дальше через нее прогоняется результат детекции и CameraX берет на себя и масштаб, и обрезку, и разницу пропорций, и поворот кадра — все, что нам пришлось бы разбирать по шагам. В итоге рамка встанет ровно на лицо при любом разрешении кадра и любом размере экрана.
Ускорение инициализации камеры
На бюджетных устройствах превью камеры может долго загружаться, и один из способов оптимизировать это — заранее сказать устройству, какую камеру будем использовать.
Когда CameraX инициализируется, она сканирует все доступные камеры на устройстве. Это занимает время: чем больше камер, тем дольше инициализация. Метод setAvailableCamerasLimiter ограничивает выбор только нужной камерой — это также защищает от попыток системы открыть «виртуальные» камеры или модули глубины, которые иногда ломают логику выбора. Эта пара строк может сэкономить время:
class MyCameraApplication : Application(), CameraXConfig.Provider { override fun getCameraXConfig(): CameraXConfig { return CameraXConfig.Builder.fromConfig(Camera2Config.defaultConfig()) .setAvailableCamerasLimiter(CameraSelector.DEFAULT_BACK_CAMERA) .build() } }
Важно! Настройка применяется один раз за всю работу приложения — если ваше приложение переключается между камерами, оптимизация сломает это переключение.
Опасность обновлений
Физическая камера внутри телефона почти всегда установлена боком. Когда мы смотрим на экран, Android незаметно переворачивает картинку. ImageAnalysis передает кадры для анализа ML, они приходят повернутыми на 90 или 270 градусов. Чтобы правильно проанализировать, каждый кадр, нужно переворачивать и тратить процессорное время.
В одном из обновлений CameraX заставили камеру автоматический поворачивать кадры на уровне железа перед отправкой в наш код, экономя процессорное время.
val imageAnalysis = ImageAnalysis.Builder() .setTargetResolution(Size(1280, 720)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) // Включаем автоповорот изображения с сенсора — пиксели сразу в правильной ориентации, // не нужно вручную поворачивать bitmap через Matrix .setOutputImageRotationEnabled(true) .build() imageAnalysis.setAnalyzer(executor) { imageProxy -> // imageProxy.imageInfo.rotationDegrees == 0, поворот уже применён val bitmap = imageProxy.toBitmap() // bitmap сразу готов к использованию без дополнительных трансформаций imageProxy.close() }
Когда мы обновили код и улучшили производительность, через неделю выяснилось, что телефоны одной из популярных марок с процессором MediaTek шлют ошибки с таким сообщением:
Crashed: Thread: SIGABRT 0x0000000000000000 #00 pc 0x53324 libc.so (BuildId: e7821228ba26f800b01ff27a87181085) #01 pc 0x532f0 libc.so (BuildId: e7821228ba26f800b01ff27a87181085) #02 pc 0x12f27c libGLESv2_mtk.so (BuildId: a1b857ecf150c5e16eca1eb767427c98) #03 pc 0x12c780 libGLESv2_mtk.so (BuildId: a1b857ecf150c5e16eca1eb767427c98)
А всему виной наша оптимизация, которую позже исправили разработчики библиотеки CameraX. Эта ситуация отложилась в голове с выводом: новые функции нужно внедрять постепенно, прикрываясь фича-флагом.
Zero-Shutter Lag: фото без задержки
Обычно, когда мы нажимаем кнопку «сделать фото», камере нужно время, чтобы сфокусироваться и захватить кадр. Если в этот момент дернуться, можно получить размытое фото. Zero-Shutter Lag (ZSL) позволяет сделать снимок мгновенно.
Включить ZSL в CameraX очень просто:
// 1. Проверяем, умеет ли это устройство ZSL if (cameraInfo.isZslSupported()) { // 2. Включаем режим в настройках ImageCapture val imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG) .build() }
Камера хранит в кольцевом буфере последние три кадра, а когда пользователь нажимает кнопку, CameraX берет из буфера тот кадр, который был снят максимально близко к моменту нажатия.
Программный фокус и экспозамер на лицо
Для качественного распознавания пользователя на бэкенде важно, чтобы лицо на фото было максимально четким и правильно освещенным. Стандартный автофокус камеры иногда фокусируется на фоне или одежде и только лишь спустя некоторое время — на лице. Чтобы это исправить и перенять управление фокусом, мы используем FocusMeteringAction.
Механика работы:
Находим лицо. ML выдает нам координаты лица, мы преобразовываем их в координаты экрана.
Создаем точку замера. Используем MeteringPointFactory, чтобы перевести координаты экрана в координаты сенсора камеры.
Запускаем фокус. Просим камеру навести резкость и настроить яркость именно в этой точке.
// 1. Получаем точку замера на основе координат центра лица из ML val factory = SurfaceOrientedMeteringPointFactory(viewWidth, viewHeight) val point = factory.createPoint(faceCenterX, faceCenterY) // 2. Создаем действие: фокус + экспозиция + баланс белого val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE) .setAutoCancelEnabled(false) // Не сбрасывать фокус через 5 секунд .build() // 3. Выполняем! Камера перенацелится прямо на лицо пользователя cameraControl.startFocusAndMetering(action)
Но держим в голове, что фокус не всесилен, всегда можно ошибиться и сфокусироваться не на лице клиента.
Как определить главное лицо
Иногда клиент находится в оживленном месте, например в кофейне или торговом центре, или даже может быть одет во что-то с принтом из лиц. Тогда детектор может находить больше одного лица в кадре. В этом случае мы проходимся по всем лицам и ищем то, которое соответствует критериям: самое большое по размеру и находится ближе к центру экрана. Это лицо и будет лицом нашего клиента.
Когда возможностей CameraX мало: Camera2Interop
Однажды у нас появилась ML-модель для распознавания размытости, работает она так: отдаем ей кадр с лицом и получаем число от 0 (резко) до 1 (размыто), эти значения считаются для каждого кадра, и как только значение пересекает заданный порог, фото отправляется на бэкенд.
Заметили, что на бюджетных устройствах значение часто держится выше порога. Это замедляло пользовательский путь: чем дольше ML думает, какой кадр выбрать, тем дольше пользователь ждет. Казалось бы, проблема в фокусе на лице, но после детального анализа выяснилось, что мелкие детали кожи и волос пропадают из-за агрессивного шумоподавления, шум убирается вместе с мелкими деталями.
Выход нашелся в Camera2Interop: это «мостик», через который можно прокинуть нужные настройки из Camera2 API прямо в CameraX. Чтобы отключить шумоподавление, понадобилась NOISE_REDUCTION_MODE:
val builder = ImageAnalysis.Builder() Camera2Interop.Extender(builder) .setCaptureRequestOption( CaptureRequest.NOISE_REDUCTION_MODE, CaptureRequest.NOISE_REDUCTION_MODE_MINIMAL )
Как это обычно бывает, некоторые устройства могут не поддерживать такую настройку, поэтому важно проверять перед включением
val characteristics = Camera2CameraInfo.from(cameraInfo).extractCameraCharacteristics() val modes = characteristics.get(CameraCharacteristics.NOISE_REDUCTION_AVAILABLE_NOISE_REDUCTION_MODES) val isSupported = modes?.contains(CaptureRequest.NOISE_REDUCTION_MODE_MINIMAL) == true
Мне хватило одной настройки, но через этот же мост доступно много чего еще, самое интересное — это STATISTICS_FACE_DETECT_MODE: если устройство имеет эту функцию, то она будет отдавать готовые координаты лица вместе с кадром, и не нужна никакая ML-модель.
Low Light Boost: видим в темноте
Если пользователь находится в слабо освещенном месте, то для качественного распознавания желательно добавить больше света. Мы делаем это путем программного увеличения яркости экрана, плавным изменением интерфейса с темного на светлый, а для флагманов используем режим Low Light Boost. Он высветляет превью и видеопоток в реальном времени, что идеально подходит для нашей задачи. Из интересного — у этого режима два способа реализации:
Аппаратный. Система управляет процессором обработки сигналов, увеличивая время экспозиции. Это дает чистую картинку с минимумом шума, но может снизить FPS в очень темных сценах.
Программный. Для устройств, у которых недоступна обработка сигнала в реальном времени, кадры обрабатываются ML-моделью HDRNet, она анализирует кадр и увеличивает яркость на лету.
Пример включения LLB:
if (cameraInfo.isLowLightBoostSupported) { cameraControl.enableLowLightBoostAsync(true) }
Лайфхак для слабых устройств
В рамках адаптации библиотеки для ее работы на платежных терминалах столкнулся с проблемой: терминал не смог справиться сразу с использованием двух UseCase (превью экрана + поток необработанных кадров для ML-анализа). Выход — отказаться от стандартного превью экрана в пользу переиспользования кадров из необработанного потока. Получился такой флоу, что необработанные кадры из ImageAnalysis рисовал на SurfaceView. Это привело к экономии и так ограниченного процессорного времени и к увеличению присылаемых кадров в поток для анализа.
imageAnalysis.setAnalyzer(executor) { imageProxy -> val bitmap = imageProxy.toBitmap() // Получаем кадр val canvas = surfaceHolder.lockCanvas() canvas?.drawBitmap(bitmap, null, destRect, null) // Рисуем сами surfaceHolder.unlockCanvasAndPost(canvas) imageProxy.close() }
Прогрев камеры: как добиться мгновенного появления картинки
После всех оптимизаций бизнес попросил, чтобы при запуске компонента сразу показывалось превью, без намеков на черный экран. Без хитрости это не решить. Мы использовали стратегию прогрева: запускать камеру до того, как пользователь откроет экран.
Механика «неморгающего» старта:
Фоновый старт ImageAnalysis. Перед тем как пользователь откроет экран, мы в фоне запускаем камеру с анализом кадров. И получается, что камера уже снимает, анализирует и ищет лучший кадр. А как откроется экран — показываем превью, убеждаемся, что перед нами все еще тот же пользователь, и отдаем идеальную картинку на наш бэкенд.
Отказ от Preview UseCase. Пришлось отказаться от стандартного превью, так как при добавлении нового UseCase экран начинает мигать. Вместо этого мы берем тот же поток для анализа фото, переиспользуем оттуда кадры и сами отрисовываем их на экране.
Более того, фото с лицом мы берем прямо из этого же потока анализа и так гарантируем, что это то самое лицо, которое прошло все проверки.
imageAnalysis.setAnalyzer(executor) { imageProxy -> val bitmap = imageProxy.toBitmap() // Тот самый кадр для анализа и отображения // 1. Анализируем лицо val result = faceDetector.process(InputImage.fromBitmap(bitmap, 0)) // 2. Если экран уже открыт — рисуем этот же кадр на SurfaceView if (isUiVisible) { val canvas = surfaceHolder.lockCanvas() canvas?.drawBitmap(bitmap, null, destRect, null) surfaceHolder.unlockCanvasAndPost(canvas) } // 3. Если условия идеальны — отдаем этот же Bitmap как финальное «фото» if (isReadyToCapture()) { sendResultToServer(bitmap) } imageProxy.close() }
Выводы
СameraX может закрыть все поставленные задачи, ее возможности каждый месяц расширяются, а трудноуловимые баги на специфичных устройствах устраняются. Радует, что всегда есть возможность самостоятельного добавления функционала из Camera2. Команда CameraX активно работает над проблемами, которые приносят разработчики — мне всего за месяц добавили нужное мне поведение.

