Как использовать элемент 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 элементов.

Комментарии (0)