Давайте распознаем позу по видео с вебкамеры вот так:

Для этого есть библиотека MediaPipe, которая может распознавать много всего в картинках, текстах и звуках. Среди прочего там есть модель для распознания положения тела на изображениях.
Здесь можно попробовать официальное демо.

Ещё есть CodePen, чтобы быстро попробовать код на JavaScript:
https://codepen.io/mediapipe‑preview/pen/abRLMxN

Но эта модель работает только в Android, iOS, на Python и JavaScript, но не во Flutter напрямую.
Кто‑то сделал пакет flutter_mediapipe, но он заброшен уже 4 года и не поддерживает веб.
Поэтому давайте подключим официальную реализацию на JavaScript в качестве собственного веб‑плагина для Flutter.
Готовое демо моего приложения — здесь (только Chrome)
Скачайте исходники здесь (потому что я буду пропускать некоторые вещи):
https://github.com/alexeyinkin/flutter‑mediapipe
Создаём плагин
Плагин — это специальный вид пакета Dart, который подключает разные имплементации в зависимости от того, под какую платформу собираем приложение.
Вот отличный официальный учебник, как писать плагины:
https://docs.flutter.dev/packages‑and‑plugins/developing‑packages
Есть ещё прекрасное введение в написание именно веб‑плагинов, от автора официального пакета url_launcher. Там рассказано, как они добавили поддержку веба в этот пакет, когда Flutter только начал поддерживать веб:
Часть 1 объясняет базовый подход — такой же, как был в плагинах для Android и iOS: так называемый method channel, чтобы делегировать что‑то нативному коду на этих платформах.
Часть 2 упрощает это и убирает method channel, потому что веб‑плагины и так написаны на Dart, а значит, можно вызывать все методы имплементации напрямую.
Обе статьи обращаются только к стандартному API браузера и не обращаются к произвольному JavaScript, взятому где‑то ещё. Поэтому в этой статье я расскажу, как обращаться к произвольному JavaScript, основываясь на всём, что вы узнали в тех статьях.
Используя архитектуру из последней статьи по url_launcher, я сделал три пакета Dart:

flutter_mediapipe_vision — главный пакет. Все приложения, которые хотят распознавать позы на изображениях, подключают его как зависимость. И только его. Он уже подтягивает в проект другие пакеты как зависимости и вызывает методы конкретной имплементации. Ненужные имплементации Flutter сам стрясёт с помощью tree‑shaking.
flutter_mediapipe_vision_platform_interface описывает интерфейс, которому каждая имплементация должна соответствовать. Этот пакет не делает ничего полезного, только подменяет имплементацию, чтобы первый пакет об этом ничего не знал.
flutter_mediapipe_vision_web — реализация для веба, главный фокус этой статьи. Он зависит от второго пакета, потому что содержит имплементацию интерфейса, описанного там, и ничего не знает о первом пакете. Первый же пакет зависит от него и подтягивает его рекурсивно во все приложения, где используется.
flutter_mediapipe_vision
Каким должен быть интерфейс конечного пользователя? Статические функции хорошо работают с подменяемыми имплементациями:
class FlutterMediapipeVision {
static Future<void> ensureInitialized() async {
await FlutterMediapipeVisionPlatform.instance.ensureInitialized();
}
static Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
return await FlutterMediapipeVisionPlatform.instance.detect(bytes);
}
}
Этот класс преобразует вызовы статических функций в вызовы методов конкретной имплементации.
Первая функция инициализирует модель. Её можно назвать как угодно, но ensureInitialized() — это удобно. Вы же помните WidgetsFlutterBinding.ensureInitialized()
Вторая функция получает байты изображения (каждого кадра) и вызывает detect() на модели. Эта функция называется именно так во всех имплементациях MediaPipe.
Обратите внимание на возвращаемый тип. Скоро мы его опишем.
flutter_mediapipe_vision_platform_interface
Типы данных
Начнём с типов. В библиотеке JavaScript, которую мы подключим, есть типы для распознанных точек и общего результата. Однако, наш плагин должен возвращать что‑то независимое от платформы, поэтому нужно описать собственные типы.
Это точка, распознанная в позе:
class NormalizedLandmark {
final double x;
final double y;
const NormalizedLandmark({required this.x, required this.y});
Offset get offset => Offset(x, y);
}
Она называется normalized, потому что x и y будут от 0 до 1, если они помещаются в кадре. Они также могут быть меньше нуля или больше единицы, если изображение обрезано и модель думает, что эта конкретная точка находится за кадром — как мой локоть здесь:

А почему бы нам не использовать Offset из dart:ui? Библиотека ещё возвращает z — расстояние от камеры, и ещё несколько интересных вещей, которые нам пока не нужны, но хорошо иметь возможность их потом добавить. Поэтому просто Offset нам не хватит.
Кроме того, этот тип NormalizedLandmark есть во всех имплементациях: TypeScript, Java, и др. Поэтому лучше, когда всё согласуется.
Дальше — результат распознания всего изображения:
class PoseLandmarkerResult {
final List<List<NormalizedLandmark>> landmarks;
const PoseLandmarkerResult.empty() : landmarks = const [];
const PoseLandmarkerResult({required this.landmarks});
}
Библиотека возвращает список распознанных поз (первое измерение списка). Каждая поза — список точек с фиксированными индексами (второе измерение списка):

Базовый класс имплементаций
С этими типами мы теперь можем описать класс, который каждая имплементация будет наследовать:
abstract class FlutterMediapipeVisionPlatform extends PlatformInterface {
FlutterMediapipeVisionPlatform() : super(token: _token);
static final Object _token = Object();
static FlutterMediapipeVisionPlatform _instance =
FlutterMediapipeVisionMethodChannel();
static FlutterMediapipeVisionPlatform get instance => _instance;
static set instance(FlutterMediapipeVisionPlatform instance) {
PlatformInterface.verify(instance, _token);
_instance = instance;
}
Future<void> ensureInitialized() {
throw UnimplementedError();
}
Future<PoseLandmarkerResult> detect(Uint8List bytes) {
throw UnimplementedError();
}
}
Тут много всего.
Самое главное — мы определяем функции бизнес‑логики ensureInitialized и detect.
Дальше — нужен какой‑то _instance по умолчанию, который мы создаём. Подробнее о нём — чуть позже.
И наконец, есть объект _token. Вот зачем он нужен. Разработчики Flutter могут что‑то поменять в базовом классе PlatformInterface, и это не должно ломать наш код. Поэтому они сказали, что этот класс можно только наследовать, но не реализовывать. Мы здесь используем extends, поэтому нам проблемы не грозят. Но вообще мы не можем контролировать, кто ещё сделает имплементацию нашего плагина под ещё какую‑нибудь платформу (или даже подменит нашу имплементацию для веба), и мы не влияем на то, будет ли у них extends или implements. Если они сделают implements, их код может какое‑то время работать, а потом внезапно сломается с каким‑нибудь обновлением для какой‑нибудь одной платформы. Поэтому мы делаем такую штуку, которая сломает их код раньше. Мы используем объект _token, у которого единственная работа — быть всегда одним и тем же (как у Зюганова). Если чья‑то реализация сделает implements, она не сможет предъявить тот самый _token, поэтому set instance выдаст ошибку.
Так, а что там за instance по умолчанию?
const MethodChannel _channel
= MethodChannel('ainkin.com/flutter_mediapipe_vision');
class FlutterMediapipeVisionMethodChannel
extends FlutterMediapipeVisionPlatform {
@override Future<void> ensureInitialized() async {
await _channel.invokeMethod<void>('ensureInitialized');
}
@override Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
final native = await _channel.invokeMethod<void>('detect');
throw UnimplementedError('TODO: Convert.');
}
}
В стародавние времена, когда Flutter поддерживал только Android и iOS, единственным способом вызвать что‑то нативное было создать объект MethodChannel и вызывать на нём «методы» с помощью invokeMethod(name). Flutter смотрел на название канала и название метода и дёргал нужный метод в нативном коде. Не было никаких подменяемых instance, потому что вся подмена работала при сборке приложения под конкретную платформу.
Для обратной совместимости, если Flutter не проист наш плагин сделать ничего необычного, мы по умолчанию должны продолжать делать то же самое. Поэтому мы закладываем это в instance по умолчанию.
Однако, мы не будем сейчас поддерживать другие платформы, кроме веба. Поэтому нам не нужна такая имплементация. Нам несложно вызвать потенциальный нативный ensureInitializee() и подождать, пока он вернёт управление. Но мы пока не можем сделать ничего осмысленного в detect(), потому что это потребует какой‑то контракт на обмен данными с нативным кодом, а мы пока не хотим с этим разбираться. Поэтому выбросим ошибку.
flutter_mediapipe_vision_web
Начнём плагин с вот этого:
class FlutterMediapipeVisionWeb extends FlutterMediapipeVisionPlatform {
static void registerWith(Registrar registrar) {
FlutterMediapipeVisionPlatform.instance = FlutterMediapipeVisionWeb();
}
Future<void>? _initFuture;
@override
Future<void> ensureInitialized() =>
_initFuture ?? (_initFuture = _initOnce());
Future<void> _initOnce() async {
// ...
}
@override
Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
// ...
}
}
registerWith() — это волшебная функция, которую Flutter вызовет где‑то на ранней стадии после запуска, если приложение собрано для веба. Создаём наш instance и устанавливаем его для использования во всех вызовах, зависящих от платформы.
Добро пожаловать в веб!
Код Dart транспилируется в JavaScript или WASM. В любом случае у него есть прямой доступ ко всему в браузере, как у любого кода на JavaScript — через глобальную переменную globalContext, определённую в dart:js_interop. Поэтому нет почти никакой разницы между объектами Dart и JavaScript, они все однородные для браузера, в котором приложение работает.
Загружаем код MediaPipe
Я позаимствовал этот кусок из Firebase и немного упростил. Обидно, что нужно самим писать код подгрузки JavaScript. Лучше бы во Flutter сделали для нас что‑то, что можно вызвать одной строкой.
Этот код загружает скрипт из src и помещает объект его модуля в глобальную переменную, определяемую windowVar:
Future<void> _injectSrcScript(String src, String windowVar) async {
final web.HTMLScriptElement script =
web.document.createElement('script') as web.HTMLScriptElement;
script.type = 'text/javascript';
script.crossOrigin = 'anonymous';
final stringUrl = src;
script.text =
'''
window.my_trigger_$windowVar = async (callback) => {
console.debug("Initializing MediaPipe $windowVar");
callback(await import("$stringUrl"));
};
''';
web.console.log('Appending a script'.toJS);
web.document.head!.appendChild(script);
Completer completer = Completer();
globalContext.callMethod(
'my_trigger_$windowVar'.toJS,
(JSAny module) {
globalContext[windowVar] = module;
globalContext.delete('my_trigger_$windowVar'.toJS);
completer.complete();
}.toJS,
);
await completer.future;
}
Значение windowVar может быть любым, если оно не конфликтует с другими глобальными переменными. Начнём initOnce() с загрузки кода MediaPipe:
const _windowVar = 'flutter_mediapipe_vision';
// ...
Future<void> _initOnce() async {
await _injectSrcScript(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.js',
_windowVar,
);
// ...
Это загрузит последнюю версию. Ещё можно было скачать её один раз и положить в assets нашего пакета, но пока хватит и этого.
Когда этот код завершится, модуль MediaPipe будет в глобальной переменной, и к нему можно будет обращаться через globalContext[_windowVar]. Можно сразу же вызывать нужные нам функции:
globalContext[_windowVar]['PoseLandmarker'].callMethod(
'createFromOptions',
...
);
Но лучше обеспечить безопасность типов.
Накладывание интерфейсов Dart на объекты JavaScript
Помните наш класс NormalizedLandmark? На стороне JavaScript ему соответствует обычный объект со свойствами x и y, к которым можно обратиться из Dart через landmark['x'] и landmark['y'], потому что у JSObject есть оператор []. Но так можно легко допустить ошибки. К счастью, мы можем наложить на этот объект интерфейс Dart:
extension type NormalizedLandmark._(JSObject _) implements JSObject {
external num get x;
external num get y;
}
И теперь, если мы приведём этот объект точки, полученный из MediaPipe, к этому классу, то компилятор гарантирует, что обращения к свойствам будут правильными:
final landmark = unsafeLandmark as NormalizedLandmark;
print(landmark.x);
Extension types
Но что это за интерфейс такой? Это конструкция extension type, которая буквально и означает наложение на любой объект интерфейса, который в ней определён — без оборачивания в объект‑адаптер. Это абстракция времени компиляции, которая не существует во время выполнения. Можете прочитать про неё в документации Dart здесь.
Давайте чуть отвлечёмся от главной задачи и разберём extension types подробнее, чтобы потом вернуться к JavaScript с этим новым знанием.
Документация Dart по extension types содержит такой пример, который сужает интерфейс int до одного оператора:
extension type IdNumber(int id) {
// Wraps the 'int' type's '<' operator:
operator <(IdNumber other) => id < other.id;
// Doesn't declare the '+' operator, for example,
// because addition does not make sense for ID numbers.
}
// ...
final safeId = IdNumber(42);
Этот код говорит, что:
Мы используем какой‑то
IdNumber, чтобы работать с какими‑то ID.Это не класс, существующий во время выполнения, потому что это слишком дорого, поэтому
extension type.Вместо класса мы используем
intдля хранения этих ID, потому чтоint— это самый дешёвый способ хранения чисел. Поэтому после названия типа идёт(int id)— это показывает, что именно мы оборачиваем.Этот интерфейс лишает наш
intвсех методов, операторов и свойств, которые мы не объявим далее.Мы описываем
operator <, и это всё, что можно делать с нашим ID.Конструктор этого типа не описан как функция‑член, потому что конструктор как функция‑член нужен только настоящим типам, которым потенциально может понадобиться несколько конструкторов, потому что в них может проделываться какая‑то работа и мы можем захотеть проделывать её по‑разному. Но у extension type конструктор — это просто оборачивание во время компиляции, которое не превращается ни в какой код, поэтому у него всегда ровно один конструктор, и не было смысла делать для него синтаксис функции‑члена. Поэтому для него сделали такой синтаксис:
(int id)сразу после названия типа.
Ладно, а как это всё применимо к нашему примеру? Он, вроде бы, совсем другой:
extension type NormalizedLandmark._(JSObject _) implements JSObject {
external num get x;
external num get y;
}
В нашем примере:
Мы оборачиваем
JSObjectи сразу же реализуем тот же интерфейсJSObject. Это значит, что мы не лишаем объект никаких членов этого интерфейса и будем только добавлять. Это нужно, потому что вскоре у нас будетJSArray<NormalizedLandmark>, аJSArrayможет принимать толькоJSObjectи его подклассы, поэтому это наследование нужно сохранить.Параметр конструктора называется
_, потому что в отличие от примера с ID мы не перенаправляем никакие вызовы на этот объект, а значит, ему не нужно никакое имя.Конструктор мы сделали приватным с помощью
._Поэтому мы можем только приводить объекты к этому типу с помощьюas, но не создавать их напрямую.Помечаем геттеры как
external. Так мы говорим компилятору, что эти свойства уже есть в объекте JavaScript, и они просто будут работать.
Когда мы оборачиваем объекты, исходящие от JavaScript или WASM, в extension type, это называется «interop type„.“»
Создаём все interop types
Нам нужно гораздо больше таких типов, чтобы создать объект, обнаруживающий позы на картинках, вызывать его методы и возвращать результат.
Можно написать эти типы вручную, глядя на исходники MediaPipe на TypeScript:
В теории все interop types в Dart можно сгенерировать из исходников TypeScript, но я пока с этим не разбирался. Написать их вручную — хорошая практика для начала.
Вот, что я выцепил из TypeScript — только те методы и свойства, которые нам понадобятся.
Результат функции detect:
extension type PoseLandmarkerResult._(JSObject _) implements JSObject {
external JSArray<JSArray<NormalizedLandmark>> get landmarks;
}
Объект, распознающий позы:
extension type PoseLandmarker._(JSObject _) implements JSObject {
external JSPromise<PoseLandmarker> createFromOptions(
WasmFileset fileset,
PoseLandmarkerOptions options,
);
external void detect(HTMLImageElement img, JSFunction callback);
}
Объект параметров для него:
extension type PoseLandmarkerOptions._(JSObject _) implements JSObject {
external PoseLandmarkerOptions({
BaseOptions baseOptions,
int numPoses,
String runningMode,
});
external BaseOptions get baseOptions;
external int get numPoses;
external String get runningMode;
}
Вложенный объект в нём:
extension type BaseOptions._(JSObject _) implements JSObject {
external BaseOptions({String modelAssetPath});
external String get modelAssetPath;
}
WasmFileset, что бы это ни значило:
extension type WasmFileset._(JSObject _) implements JSObject {}
Fileset resolver:
extension type FilesetResolver._(JSObject _) implements JSObject {
external JSPromise<WasmFileset> forVisionTasks(String basePath);
}
И наконец, главный объект модуля MediaPipe:
import 'fileset_resolver.dart' as fsr;
import 'pose_landmarker.dart' as plm;
extension type MediaPipe._(JSObject _) implements JSObject {
external fsr.FilesetResolver get FilesetResolver;
external plm.PoseLandmarker get PoseLandmarker;
}
Инициализируем модель
Продолжим писать функцию инициализации плагина:
MediaPipe get mp => globalContext[_windowVar] as MediaPipe;
PoseLandmarker? _landmarker;Future<void> _initOnce() async {
await _injectSrcScript(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.js',
_windowVar,
);
final fs = await mp.FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm',
).toDart;
final options = PoseLandmarkerOptions(
baseOptions: BaseOptions(
modelAssetPath:
"packages/flutter_mediapipe_vision_platform_interface/assets/"
"assets/models/pose_landmarker_lite.task",
),
numPoses: 5,
runningMode: "IMAGE",
);
_landmarker = await mp.PoseLandmarker.createFromOptions(fs, options).toDart;
}
Файл модели ещё нужно скачать отдельно (я выбрал версию lite) вот здесь:
https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker
Поскольку модель одна и та же для MediaPipe на всех платформах, лучше всего положить её в общий пакет, а не в веб‑пакет. Лучше всего подойдёт flutter_mediapipe_vision_platform_interface, потому что все имплементации от него зависят, хотя формально модель не относится к интерфейсу.
В общем, когда функция завершится, мы получим объект, обнаруживающий позы, в поле _landmarker.
Распознаём позы
Всю работу делает этот метод:
@override Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
final el = await _createImageFromBytes(bytes);
// ...
}
Начинаем с создания HTMLImageElement из переданных байт, потому что функция detect в MediaPipe принимает именно его. Вот так:
Future<web.HTMLImageElement> _createImageFromBytes(Uint8List bytes) async {
final completer = Completer();
final blob = web.Blob(
[bytes.toJS].toJS,
web.BlobPropertyBag(type: _detectImageFormat(bytes)),
);
final imageUrl = web.URL.createObjectURL(blob);
final el = web.document.createElement('img') as web.HTMLImageElement;
el.onload = () {
web.URL.revokeObjectURL(imageUrl);
completer.complete();
}.toJS;
el. {
web.URL.revokeObjectURL(imageUrl);
completer.completeError('Cannot load the image.');
}.toJS;
el.src = imageUrl;
await completer.future;
return el;
}
В JavaScript конструктор объекта Blob (binary long object) принимает двумерный массив байт. Поэтому сначала превращаем на Uint8List в массив JavaScript, вызывая его геттер .toJS. Он есть у многих типов Dart, чтобы превратить их во что‑то, подходящее для передачи в функции JavaScript. Потому что код Dart хоть и транспилируется в JavaScript, но всё‑таки иногда он транспилируется в свои особые объекты с несовместимым интерфейсом, а иногда компилятору просто нужно такое приведение для формальной правильности типов. После этого оборачиваем этот массив в ещё один List и преобразуем его тоже в массив JavaScript, чтобы получить нужный двумерный массив.
Формат картинки определяем по первым байтам, я здесь пропущу функцию _detectImageFormat.
Дальше нам нужен какой‑то URL, который мы установим в наш тег img, потому что только так картинки можно поместить в элемент HTML.
Для этого используется так называемый blob URL. Так мы говорим браузеру: «Эй, нам надо показать эти байты в объекте img. Дай нам, пожалуйста, какой‑нибудь виртуальный URL, чтобы он на них ссылался.»
Браузер сохраняет эти байты куда‑то к себе во внутреннюю таблицу и даёт нам «талончик», по которому страница может обращаться к этой картинке. Он выглядит примерно так:blob:http://localhost:40000/fd108f07-5e55-43d1-b5cd-691b973c03d6
Это внутренняя штука для даннй сессии браузера. Интересно, что по такому адресу можно даже открыть картинку в новой вкладке:

Короче, мы создаём объект img и устанавливаем наш URL в его src. Теперь нужно дождаться, когда картинка загрузится. Для этого нужно указать два обработчика:
el.onload = () {
web.URL.revokeObjectURL(imageUrl);
completer.complete();
}.toJS;
el.onerror = () {
web.URL.revokeObjectURL(imageUrl);
completer.completeError('Cannot load the image.');
}.toJS;
Они оба завершают completer, так что функция может вернуть готовый к работе img или выбросить ошибку. Ещё они освобождают URL, чтобы не тратить память браузера. В конце концов, мы будем делать это для каждого кадра.
Ещё обратите внимание, что когда мы передаём функцию Dart как колбэк JavaScript, нужно преобразовать её в функцию JavaScript с помощью геттера toJS.
Когда мы получили элемент img, можно передавать его в функцию detect:
import 'src/interop/pose_landmarker_result.dart' as js_plr;
// ...
@override
Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
PoseLandmarkerResult r = PoseLandmarkerResult.empty();
final el = await _createImageFromBytes(bytes);
_landmarker!.detect(
el,
(js_plr.PoseLandmarkerResult? result) {
r = result?.toDart ?? PoseLandmarkerResult.empty();
}.toJS,
);
return r;
}
Обратите внимание, что функция detect в MediaPipe не возвращает результат, а передаёт его в колбэк. Это позволяет ей освободить память, когда вызов завершится. На практике объект переживает этот колбэк, но на это нельзя полагаться. Нужно вытащить из этого объекта всё, что нам нужно, пока работает колбэк, и положить это в наш кросс‑платформенный объект результата, который мы определили во втором пакете.
Вот и весь код наших пакетов!
Связываем пакеты вместе
Пакет с веб‑имплементацией должен объявить в своём pubspec.yaml, что он содержит имплементацию плагина, чтобы Flutter знал, какой метод вызвать при запуске приложения, чтобы подменить имплементацию на эту:
flutter:
plugin:
platforms:
web:
pluginClass: FlutterMediapipeVisionWeb
fileName: flutter_mediapipe_vision_web.dart
Пакет с интерфейсом должен объявить asset с моделью, чтобы она попадала в сборки приожений, которые используют пакет:
flutter:
assets:
- assets/models/pose_landmarker_lite.task
А публичный пакет для пользователей должен заэндорсить пакет с веб‑имплементацией:
flutter:
plugin:
platforms:
web:
default_package: flutter_mediapipe_vision_web
Приложение
Показываем видео с камеры
Первым делом нужно просто показать видео с камеры на экране. Для этого создадим контроллер камеры и покажем видео в виджете CameraPreview:
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
late CameraController cameraController;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterMediapipeVision.ensureInitialized();
cameraController = CameraController(
(await availableCameras()).first,
ResolutionPreset.low,
enableAudio: false,
);
await cameraController.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('MediaPipe demo')),
body: Center(
child: CameraPreview(cameraController),
),
),
);
}
}
Это минимальное приложение, чтобы показывать видео с камеры на экране. Тут есть недостатки. Например, приложение запрашивает разрешение на видео в самом начале, ещё ничего не показывая и не объясняя пользователю, и на это время всё блокируется. И никак не обрабатывает отказ. Но оно делает самое главное:

Получаем и анализируем кадры
Давайте напишем контроллер для распознания:
class InferenceController extends ChangeNotifier {
final CameraController cameraController;
PoseLandmarkerResult get lastResult => _lastResult;
PoseLandmarkerResult _lastResult = PoseLandmarkerResult.empty();
InferenceController({required this.cameraController});
Future<void> start() async {
while (true) {
await _tick();
}
}
Future<void> _tick() async {
final file = await cameraController.takePicture();
final bytes = await file.readAsBytes();
_lastResult = await FlutterMediapipeVision.detect(bytes);
notifyListeners();
}
}
Когда вызывается start(), он работает бесконечно. С этим будут проблемы на мобильных устройствах, когда приложение может быть вытеснено из памяти, но этого достаточно для минимальной веб‑версии.
В цикле мы получаем кадры с помощью cameraController.takePicture(), потом передаём их байты в плагин и сохраняем результат анализа.
Давайте создадим этот контроллер в main():
late InferenceController inferenceController; // ИЗМЕНЕНО
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterMediapipeVision.ensureInitialized();
final cameraController = CameraController(
(await availableCameras()).first,
ResolutionPreset.low,
enableAudio: false,
);
await cameraController.initialize();
// ДОБАВЛЕНО:
inferenceController = InferenceController(cameraController: cameraController);
unawaited(inferenceController.start());
runApp(const MyApp());
}
Показываем скелет поверх видео
Для этого сделаем виджет CameraOverlayWidget:
class CameraOverlayWidget extends StatelessWidget {
final InferenceController inferenceController;
const CameraOverlayWidget({required this.inferenceController});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: inferenceController,
child: CameraPreview(inferenceController.cameraController),
builder: (context, child) {
return CustomPaint(
foregroundPainter: CameraOverlayPainter(
inferenceController: inferenceController,
),
willChange: true,
child: child,
);
}
);
}
}
Этот виджет слушает контроллер распознания и перестраивается при каждом уведомлении о его обновлении. Обратите внимание, что мы создаём виджет CameraPreview вне функции builder и передаём его как child в ListenableBuilder. Это исключает CameraPreview из перестройки на каждом кадре и экономит ресурсы.
Виджет CustomPaint применяет foregroundPainter, чтобы рисовать поверх child.
Давайте сделаем этот CameraOverlayPainter:
class CameraOverlayPainter extends CustomPainter {
final InferenceController inferenceController;
static final _paint = Paint()
..color = Colors.white
..isAntiAlias = true
..style = PaintingStyle.fill
..strokeWidth = 5;
static const _pointRadius = 5.0;
CameraOverlayPainter({required this.inferenceController});
@override void paint(Canvas canvas, Size size) {
_paintPose(canvas, size);
}
void _paintPose(Canvas canvas, Size size) {
final pose = inferenceController.lastResult.landmarks.firstOrNull;
if (pose == null) {
return;
}
final leftShoulder = pose[Points.leftShoulder].offset.timesSize(size);
final rightShoulder = pose[Points.rightShoulder].offset.timesSize(size);
// Same for every point.
_paintLine(canvas, leftShoulder, rightShoulder);
// Same for every line.
_paintPoint(canvas, leftShoulder);
_paintPoint(canvas, rightShoulder);
// Same for every point.
}
void _paintPoint(Canvas canvas, Offset offset) {
canvas.drawCircle(offset, _pointRadius, _paint);
}
void _paintLine(Canvas canvas, Offset pt1, Offset pt2) {
canvas.drawLine(pt1, pt2, _paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
extension on Offset {
Offset timesSize(Size size) => Offset(dx * size.width, dy * size.height);
}
abstract final class Points {
static const leftShoulder = 11;
static const rightShoulder = 12;
// Same for every point.
}
Этот класс выбирает из распознанного результата главные точки, которые нас интересуют, и соединяет их линиями. Все координаты от 0 до 1, поэтому умножаем их на size — текущий размер виджета. Поскольку виджет поверх видео и имеет такой же размер, все координаты правильны.
Наш финальный результат:

Вот задеплоенное демо ещё раз:
https://alexeyinkin.github.io/flutter‑mediapipe/
Совместимость с браузерами
В Chrome всё работает.
В Firefox 144 не работает, потому что в пакете camera есть баг, который я скоро опишу и отправлю.
В Safari не работает просто так, без всяких симптомов. Если знаете, почему — скажите.
Дальше
В следующей статье доработаем архитектуру и используем распознанные точки, чтобы распознать движения более высокого уровня.
Подпишитесь в Telegram, чтобы не пропустить: ainkin_com
Русские переводы реже и с задержкой здесь: ainkin_com_ru