Распознавание лиц с потока камеры в .NET MAUI

от автора

Как использовать элемент SkiaCamera для AI/ML локально и с API

В этой статье

Сегодняшние приложения для мобильных и настольных устройств умеют распознавать на изображениях почти что угодно, — от QR-кодов до количества калорий в еде на на фото. На платформах, которые поддерживает .NET MAUI, для этого можно использовать разные варианты, как локальные ML-движки вроде TensorFlow Lite, нативные SDK для конкретной платформы, типа ARKit на iOS, так и разные Vision API. Далее все зависит уже от реализации в приложении.

И вот, когда речь идет пойдет о распознавании изображений от камеры, наш вариант — пакет DrawnUi.Maui.Camera. В предыдущей статье я показывал, как использовать SkiaCamera для анализа аудио с AI в реальном времени, а сегодня займемся видео: разберем на примере распознавания лиц.

Приложение-пример, которое идет вместе с этой статьей, использует локальное распознавание лицевых точек с помощью MediaPipe Tasks. Я выбрал этот вариант ради максимально единообразного поведения на всех платформах: на iOS, Android и Windows.

А также наше приложение рисует оверлеи и приклеивает маски к движущимся лицам.

Важно: сегодня наша цель — показать как использовать живые видеокадры из SkiaCamera для AI/ML локально и через API в целом, не уходя глубоко в детали конкретного приложения.

Настройка

О том, как установить и инициализировать SkiaCamera, я писал в предыдущей статье. В данном примере мы используем XAML и размещаем унаследованный элемент внутри обычного лейаута .NET MAUI.

Для задач AI/ML нам нужно заставить элемент работать в режиме обработки поступающего видео-потока:

UseRealtimeVideoProcessing = true;

Точка подключения

Когда SkiaCamera показывает превью, кадры, которые вы видите на экране, находятся в GPU-памяти. Чтобы использовать их асинхронно для своих целей нам нужно вытащить кадр нужного размера в обычную память. Ключевой виртуальный метод: OnRawFrameAvailable(RawCameraFrame frame).

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

Для локальной ML модели

Структура RawCameraFrame предоставляет метод TryGetRgba(width, height, buffer, orientation, cropRatio), который заполняет заранее выделенный вами byte[] RGBA-пикселями в том финальном размере, который нужен вашей модели.

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

В приложении-примере используется cropRatio по умолчанию, то есть 1 без дополнительного зума (читай, обрезки полей), и orientation по умолчанию OutputOrientation.Display — в данном приложении нам не было важно, чтобы картинка была строго “головой вверх”; нам было важно получить ровно то, что пользователь видит на экране, даже если устройство повернуто в ландшафт.

Если для вашей модели важна ориентация “головой вверх”, то можно использовать OutputOrientation.Portrait. И, возможно, вам еще захочется подрезать кадр, например, убрать края, если нужный объект почти наверняка находится ближе к центру. Для этого можно уменьшить cropRatio. Например: 0.9 будет означать, что вы обрежете пропорцию 0.1 по краям кадра.

В нашем примере метод вызывается вообще с дефолтными значения, без явной передачи orientation, cropRatio):

if (!frame.TryGetRgba(targetWidth, targetHeight, _mlFrameBuffers[writeBufferIndex]))return;

Даже для локального модели лучше всего будет пропускать кадры из видео-потока, пока детектор еще занят. Это касается не только лиц, но и QR-сканирования, OCR, разпознавания объектов, в общем любого сценария, где модель работает нон-стоп. Лучше пропустить часть кадров, чем превью камеры начнет лагать.

Вот пример для абстрактного ML-сценария: не блокируем поток камеры, с пропуском кадров, пока предыдущее распознавание еще идет в другом потоке:

private readonly byte[] _rgbaBuffer = new byte[targetWidth * targetHeight * 4];private readonly SemaphoreSlim _detectorBusy = new(1, 1);protected override void OnRawFrameAvailable(RawCameraFrame frame){if (!_detectorBusy.Wait(0))return;if (!frame.TryGetRgba(targetWidth, targetHeight, _rgbaBuffer, OutputOrientation.Portrait, 0.8f)){_detectorBusy.Release();return;}var snapshot = _rgbaBuffer.ToArray();_ = Task.Run(async () =>{try{await detector.EnqueueDetectionAsync(snapshot, request);}finally{_detectorBusy.Release();}});}

Следующий пример более оптимизирован: вместо ToArray() используется переиспользуемый пул буферов, а работа уходит идет в параллельном потоке, которым управляет ваш детектор, без лишнего оборачивания в Task.Run:

private readonly byte[][] _mlBuffers =[new byte[targetWidth * targetHeight * 4],new byte[targetWidth * targetHeight * 4]];private const float MlCropRatio = 1f;private readonly object _detectionSync = new();private int _activeBufferIndex = -1;private DetectionWorkItem? _queuedDetectionWorkItem;protected override void OnRawFrameAvailable(RawCameraFrame frame){DetectionWorkItem? workItemToSubmit = null;lock (_detectionSync){int writeBufferIndex = _activeBufferIndex == 0 ? 1 : 0;if (!frame.TryGetRgba(targetWidth, targetHeight, _mlBuffers[writeBufferIndex], OutputOrientation.Portrait, MlCropRatio))return;var workItem = new DetectionWorkItem(writeBufferIndex,targetWidth,targetHeight,0);if (_activeBufferIndex >= 0){_queuedDetectionWorkItem = workItem;return;}_activeBufferIndex = workItem.BufferIndex;workItemToSubmit = workItem;}detectionPipeline.Submit(workItemToSubmit);}

Здесь фоновый поток принадлежит самому детектору. OnRawFrameAvailable(...) только подготавливает кадр, решает, надо ли его пропустить или поставить в очередь, и затем передает дальше. В коллбэке завершения позже освобождается активный буфер и, если нужно, отправляется самый свежий отложенный кадр. Поскольку в этом примере используется OutputOrientation.Display, буфер детектора уже выровнен относительно живого превью, и потом не нужно отдельно компенсировать поворот в координатах детектора.

Для AI API

В приложении используется локальный ML движок, но та же точка подключения подойдет и в случае, если вы хотите работать через API.

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

Для публичных LLM vision API обычно отправляют JPEG или PNG. Параметр cropRatio доступен и здесь:

private const int RemoteUploadIntervalMs = 300;private long _lastUploadStartedAtMs;private readonly SemaphoreSlim _uploadGate = new(1, 1);protected override void OnRawFrameAvailable(RawCameraFrame frame){if (!_uploadGate.Wait(0))return;long nowMs = Environment.TickCount64;if (nowMs - _lastUploadStartedAtMs < RemoteUploadIntervalMs){_uploadGate.Release();return;}if (!frame.TryGetJpeg(targetWidth, targetHeight, out var payload, 100, OutputOrientation.Portrait, 1f)){_uploadGate.Release();return;}_lastUploadStartedAtMs = nowMs;_ = Task.Run(async () =>{try{await apiClient.UploadImageAsync(payload, "image/jpeg");}finally{_uploadGate.Release();}});}

Здесь аккуратнее всего работает SemaphoreSlim.Wait(0): он не блокирует коллбэк камеры, но при этом гарантирует, что одновременно в полете будет только одна отправка. Уже далее можно спокойно проверить минимальную паузу в 300 мс и обновить _lastUploadStartedAtMs. Если сетевой вызов занимает дольше 300 мс, то новые кадры будут просто пропускаться.

TryGetJpeg(...) и TryGetPng(...) возвращают изображение в том размере и с той ориентацией, которые вы запросили.

Если ваш ендпойнт принимает сырые данные RGBA8888, можно по-прежнему использовать TryGetRgbaBytes(...).

Отладка

Если нужно проверить, что вы реально отправляете в AI/ML, можно сохранить один кадр изображения в галерею устройства и посмотреть глазами. Простой способ убедиться, что с ориентацией, обрезкой все действительно так, как вы ожидаете. Не забудьте дать приложению доступ к галерее, см. README SkiaCamera, там всё описано.

Если приложение уже использует TryGetJpeg(...), можно сохранить ровно тот же самый JPEG:

private bool _saveNextDebugFrame; //установим в true когда надо сохранить текущий кадр в галереюprotected override void OnRawFrameAvailable(RawCameraFrame frame){if (_saveNextDebugFrame &&frame.TryGetJpeg(targetWidth, targetHeight, out var payload, 100, OutputOrientation.Portrait, 1f)){_saveNextDebugFrame = false;_ = Task.Run(async () =>{using var stream = new MemoryStream(payload);await NativeControl.SaveJpgStreamToGallery(stream,$"ml_debug_{DateTime.Now:yyyyMMdd_HHmmss}.jpg",new Metadata(),"DebugAlbum");});}// ...}

Если же приложение использует TryGetRgbaBytes(...), то нужно закодировать полученный RGBA-буфер в JPEG и уже потом сохранить в галерею:

private bool _saveNextDebugFrame;protected override void OnRawFrameAvailable(RawCameraFrame frame){if (_saveNextDebugFrame &&frame.TryGetRgbaBytes(targetWidth, targetHeight, out var rgbaBytes, OutputOrientation.Portrait, 1f)){_saveNextDebugFrame = false;_ = Task.Run(async () =>{var imageInfo = new SKImageInfo(targetWidth,targetHeight,SKColorType.Rgba8888,SKAlphaType.Unpremul);using var image = SKImage.FromPixelCopy(imageInfo, rgbaBytes, imageInfo.RowBytes);using var data = image.Encode(SKEncodedImageFormat.Jpeg, 100);using var stream = data.AsStream();await NativeControl.SaveJpgStreamToGallery(stream,$"ml_debug_rgba_{DateTime.Now:yyyyMMdd_HHmmss}.jpg",new Metadata(),"DebugAlbum");});}// ...}

Чем меньше размер, который вы запрашиваете, тем быстрее пройдет операция GPU кадр -> CPU миниатюра.

Приложение-пример

Теперь, когда нам понятно, как получать изображения для AI/ML, читать исходники приложения будет проще. Я добавил и дополнительную документацию (на английском): Implementation.md, где разобрана архитектура, и Includes.md, где объясняется, как ML-модели зашиваются внутри ресурсов приложения для каждой платформы. Ибо всю нашу схему легко адаптировать и под другие MediaPipe Tasks: просто меняете модель и парсите другой результат. О том какие еще модели можно подключить, — чуть ниже.

Маска, наложенная на обнаруженные лицевые точки

Маска, наложенная на обнаруженные лицевые точки

Чтобы можно было рисовать маски-картинки, например маску Человека-паука или Смешную шляпу, мы используем конфигурации, которые задают позиционирование относительно найденного лица:

config = ModePicker.SelectedIndex switch{3 => new MaskConfiguration{Filename = "hat_cake.png",Position = MaskPosition.Top,WidthMultiplier = 1.6f,YOffsetRatio = 0.05f},_ => new MaskConfiguration{Filename = "mask_spiderman.png",Position = MaskPosition.Inside,WidthMultiplier = 1.25f,YOffsetRatio = -0.2f}};await CameraControl.SetupMaskAsync(config);

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

Чтобы рисовать с максимальным фпс, мы держим текущий растр маски в текстуре на GPU:

   //грузим из ресурсов   using var stream = await FileSystem.OpenAppPackageFileAsync(config.Filename);   using var managed = new MemoryStream();   await stream.CopyToAsync(managed);   managed.Position = 0;   MaskBitmap = SKBitmap.Decode(managed);      //выполняем в GPU потоке: сохраняем в GPU текстуру   SafeAction(() => //выполняется в конце отрисовки холста с помощью SkiaSharp   {   using var gpu = this.CreateSurface(MaskBitmap.Width, MaskBitmap.Height, isGpu: true);   gpu.Canvas.Clear(SKColors.Transparent);   gpu.Canvas.DrawBitmap(MaskBitmap, 0, 0);   gpu.Canvas.Flush();   MaskImage = gpu.Snapshot();   });

После этого мы можем рисовать MaskImage прямо в коллбэке ProcessFrame у SkiaCamera, с правильной проекцией поворота и позиции.

Тот же код рисования оверлея, который мы используем в ProcessFrame, работает у нас и при сохранении снятых фотографий. Фото может быть очень большим, например 4000x3000, и если рисовать найденные landmark-точки или маски со толщиной stroke, рассчитанной для маленького превью, примитивы SkiaSharp на таком размере будут почти не видны. Мы решаем это масштабированием толщины линии от безопасной базы в 300 пикселей:

var density = Math.Min(frame.Width, frame.Height) / 300f; _paintDetectionDotsStroke.StrokeWidth = Math.Max(2f, 2f * density); //рисуем лендмарки - лицевые точкиframe.Canvas.DrawPoints(SKPointMode.Points, pts, _paintDetectionDotsStroke);

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

Чтобы перемещения маски в кадре при движении головы выглядело плавнее, мы используем One Euro фильтр. Он работает отдельно для каждой landmark-точки, по X и Y, поэтому на неподвижном лице хорошо убирает дрожание, а на движущемся уменьшает шаги перемещения. Дополнительный, шаг обработки prediction step (предсказание) экстраполирует положение по двум последним распознаваниям и помогает компенсировать задержку детектора, когда голова двигается быстро.

Что еще можно распознавать

Архитектура — MediaPipeTasksVision на мобильных платформах и MediaPipe.Net TFLite graphs на Windows — так же переносится и на другие задачи: достаточно заменить файл модели:

  • Hand landmarks (hand_landmarker.task) — 21 3D-точка суставов на каждую руку, отслеживание жестов

  • Pose landmarks (pose_landmarker.task) — 33 суставные точки тела, отслеживание движений (фитнес, 3D…)

  • Object detection (efficientdet.task) — определение объектов

  • Image segmentation (image_segmenter.task) — попиксельное разделение фон/бэкграунд (тот же механизм лежит в основе размытия фона в Zoom)

  • Image classification — классификация всего изображения

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

Используемые пакеты

  • Windows: Mediapipe.Net и Mediapipe.Net.Runtime.CPU.

  • iOS: MediaPipeTasksVision.iOS из проекта MediaPipeTasks.

  • Android: AppoMobi.Preview.MediaPipeTasksVision.Android, мой форк MediaPipeTasksVision.Android с дополнительными методами для пакетного чтения landmark-точек, что уменьшает время обработки кадра примерно в 3 раза. PR уже отправлен в основной репозиторий, так что позже, возможно, получится вернуться к оригинальному NuGet-пакету из MediaPipeTasks.

Заключение

Отправлять кадры из живого превью камеры в локальную ML-модель или на API в .NET MAUI вполне реально и достаточно комфортно. Показатели производительности в строке состояния в приложении-примере помогут вам в настройке.

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

Ссылки и ресурсы


Автор открыт для сотрудничества в создании мобильных приложений на .NET MAUI, и кастомных UI элементов.

ссылка на оригинал статьи https://habr.com/ru/articles/1027582/