使用 MediaPipe 在 Flutter web 中识别姿势

咱们就来像这样识别视频里的姿势吧!

有一个名为 MediaPipe的库,它可以在图像、文本和声音上完成许多识别和检测任务。其中包括一个用于在图像上识别姿势的模型。

你可以点击这里,试试它的官方演示: mediapipe-studio.webapps.google.com/demo/pose_l...

它还有一个 CodePen 代码片段 ,方便你用 JavaScript 快速进行实践操作(或"上手"): codepen.io/mediapipe-p...

然而,该模型目前仅支持在 Android、iOS、Python 和 JavaScript 环境下运行,并不能直接在 Flutter 中使用

有人曾创建了一个名为 flutter_mediapipe 的 package,但它已在四年前被弃用,并且不支持 Web 端

因此,我们来将官方的 JavaScript 实现封装 到我们自己的 Flutter 插件中。

你可以在这里查看第一个截图中的最终演示应用(仅支持 Chrome 浏览器 ): alexeyinkin.github.io/flutter-med...

下载源代码以便跟着操作(我会跳过一些内容): github.com/alexeyinkin...

🔌 创建插件 (Creating the plugin)

插件(Plugin)是一种特殊的 Dart package,它会根据你编译的目标平台来置换(或"切换")不同的实现。


📚 学习资源 (References)

这份官方文章是编写插件的优秀教程: docs.flutter.dev/packages-an...

另外,url_launcher 官方 package 的作者也撰写了一篇精彩的入门文章,专门介绍如何编写 Web 插件。它解释了 Flutter 刚开始支持 Web 时,他们是如何首次为该 package 添加 Web 支持的:

  • Part 1 解释了基本方法,这与 Android 和 iOS 插件的做法相同,即使用一种称为 Method Channel(方法通道) 的机制,将任务委托给这些平台上的原生代码
  • Part 2 通过移除方法通道 简化了流程,因为 Web 实现代码无论如何都是用 Dart 编写的,因此你可以直接调用特定实现的方法

这两篇文章都只使用了标准的浏览器 API,没有调用任何自定义的 JavaScript 。因此,本文将在它们的基础上构建,并增加了导入和调用自定义 JavaScript 的功能


遵循最新的 url_launcher 文章中的架构,我创建了三个 Dart packages:

  • flutter_mediapipe_vision主 package 。所有想要在图像上识别姿势的应用都需要且仅需将其添加为依赖项。它会引入其他 package 作为依赖,并根据平台将调用转发 给特定的实现。同时,Flutter 会对其他平台的实现进行摇树优化 (tree-shakes)

  • flutter_mediapipe_vision_platform_interface 定义了所有平台实现必须遵循的接口 。这个 package 本身不执行任何实际工作,它的全部作用是在第一个 package 不知情的情况下置换(或"切换")不同的实现。

  • flutter_mediapipe_vision_web针对 Web 的特定实现 ,也是本文的主要关注点。它依赖于第二个 package,因为它实现了相同的接口。它对第一个 package 一无所知。反过来,第一个 package 依赖于它,只是为了递归地将其带入项目中。

lutter_mediapipe_vision

我们希望为面向用户的 package 设计一个怎样的接口呢?如果我们想要可置换的实现静态函数是最好的选择:

dart 复制代码
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() 函数。在所有平台实现中,它的命名都保持一致。

另外,请注意我们很快将定义的返回类型


flutter_mediapipe_vision_platform_interface

📊 数据类型 (Data types)

我们从数据类型开始。我们将要连接的 JavaScript 库有用于已识别点和总聚合结果的数据类型。然而,我们需要插件返回与平台无关的结果,因此我们需要定义自己的类型。

这是一个地标点(landmark),即姿势中被识别出来的一个点:

dart 复制代码
class NormalizedLandmark {
  final double x;
  final double y;

  const NormalizedLandmark({required this.x, required this.y});

  Offset get offset => Offset(x, y);
}

它被称为归一化 (normalized),是因为如果 xy 坐标在画面帧内,它们的值范围就在 0 到 1 之间。

如果图像被裁剪,并且模型认为某个特定的点在图像外部,那么这些坐标也可能小于零或大于 1,就像下面这个网络摄像头示例一样:

为什么不直接使用来自 dart:uiOffset 类型呢?

这个库(指 MediaPipe)还会给我们 z 轴坐标 (即到摄像机的距离)以及一些我们目前不需要的其他信息,但以后能添加它们会很有益处 。因此,Offset 类型是不够的

另外,这个 NormalizedLandmark 类型是在每个独立的实现中都有定义的:TypeScript、Java 等。所以,让我们保持一致性。

接下来,这是识别结果

dart 复制代码
class PoseLandmarkerResult {
  final List<List<NormalizedLandmark>> landmarks;

  const PoseLandmarkerResult.empty() : landmarks = const [];

  const PoseLandmarkerResult({required this.landmarks});
}

该库返回已识别姿势的列表 (即列表的第一个维度)。每个姿势都是一个地标点列表,它们位于特定的索引上(即列表的第二个维度):


💻 平台接口 (The platform interface)

在数据类型定义完毕后,我们就可以定义每个插件都必须扩展(继承)的接口了:

dart 复制代码
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();
  }
}

📌 关键点解析

这里涉及许多内容。

最重要的是,我们定义了两个业务逻辑函数ensureInitializeddetect

接下来,_instance 需要有一个默认值,因此我们创建了一个实例,我们稍后会讨论它。

最后,请注意名为 _token 的对象。这是我们需要它的原因。Flutter 保留向其 PlatformInterface 类添加内容的权利,而这对我们来说不应该成为一个破坏性变更 (breaking change) 。因此,规则是始终使用 extends(继承),而不是 implements(实现)

我们确保在这里使用了 extends,但通常任何人都可以为另一个平台编写我们平台接口的实现(甚至可以覆盖我们在同一平台上的实现),而我们无法控制他们使用 extends 还是 implements。如果他们使用了 implements,程序可能暂时能正常工作,但随后可能会突然停止对特定平台上的这个 package 进行构建。

因此,我们确保不等到那时,而是提前破坏(报错) 。为此,我们使用了 _token 对象,它唯一的职责就是确保一致性 。如果别人的插件实现了我们的接口,它就不会拥有相同的 _token,并且 set instance 中的检查将会失败。

那么,那个默认实例到底是什么呢?

dart 复制代码
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 早期 (Back when Flutter only supported Android and iOS)

回顾 Flutter 仅支持 Android 和 iOS 的时期,调用任何平台特定功能的唯一方式 是创建一个名为 MethodChannel 的对象,并使用 invokeMethod(name) 在其上"调用方法"。Flutter 会处理 Channel(通道)Method(方法) 的名称,并将调用路由到特定的原生代码 。Dart 代码中没有可置换的实例 ,因为所有的置换都是在构建应用时完成的。

为了保持向后兼容性 ,如果 Flutter 没有要求我们的插件做任何不同的事情,这就是我们需要默认采用的方式。这也是我们将此作为默认实例的原因。


🚧 默认实例的实现 (Default Instance Implementation)

不过,我们暂时不会支持 Web 以外的平台 。因此,我们不需要让 MethodChannel 的实现能够正常工作。调用一个假设的原生 ensureInitializee() 并等待它返回并不会有什么坏处。但是,我们不能在 detect() 中做任何有意义的事情,因为那需要一个用于与原生实现之间传递数据 的契约。因此,我们可以在那里抛出一个错误


💻 flutter_mediapipe_vision_web

让我们用以下代码开始我们的插件:

dart 复制代码
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())

registerWith() 是一个"神奇"的函数。如果应用是为 Web 构建 的,Flutter 会在很早的时候 调用它。然后,我们创建当前类的一个实例,并将其设置为用于所有平台调用


🌐 欢迎来到 Web!(Welcome to Web!)

Dart 代码会被转译 (transpile) 成 JavaScript 或 WASM (WebAssembly)。无论是哪种方式,它都可以通过 dart:js_interop 导入提供的 globalContext 变量,直接访问浏览器的全局作用域 。因此,Dart 对象和 JavaScript 对象之间几乎没有区别,它们对于运行我们应用的浏览器来说都只是对象


📦 加载 MediaPipe 的 JavaScript (Loading MediaPipe's JavaScript)

借鉴了 Firebase 的这段代码并做了一些简化。遗憾的是,我们需要重复这段较长的代码片段,而 Flutter 尚未为我们准备好一个单行代码 (one-liner) 解决方案。

这段代码从 src 加载一个脚本,并将其模块对象 存储在一个由 windowVar 确定的全局变量中。

dart 复制代码
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,让它的命名不会与 Flutter 或 MediaPipe 产生冲突 ,并开始用加载 MediaPipe 代码的方式来编写我们的 _initOnce() 函数:

dart 复制代码
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] 进行访问。我们现在就可以开始从它那里调用函数了:

dart 复制代码
globalContext[_windowVar]['PoseLandmarker'].callMethod(  
'createFromOptions',  
...  
);

但是,最好对它进行某种类型安全的约束。

🛡️ 对 JavaScript 对象施加 Dart 接口 (Imposing Dart interfaces on JavaScript objects)

还记得我们声明的 NormalizedLandmark 类吗?

在 JavaScript 端,它是一个带有 xy 属性的常规对象 ,在我们的 Dart 代码中可以通过 landmark['x']landmark['y'] 这样的方式来访问。这很容易出错。幸运的是,我们可以像这样定义一个 Dart 接口:

dart 复制代码
extension type NormalizedLandmark._(JSObject _) implements JSObject {  
external num get x;  
external num get y;  
}

如果我们将这样一个地标对象强制转换 (cast)为这个类,我们就能以类型安全的方式来访问它的属性了:

dart 复制代码
final landmark = unsafeLandmark as NormalizedLandmark;  
print(landmark.x);

🌟 扩展类型 (Extension types)

这个接口到底是什么呢?它是一种名为扩展类型 (extension type)的构造,它在字面上给对象施加了一个接口,但并不会创建一个额外的包装器 。它作为一种编译时抽象 (compile-time abstraction)而存在,但在运行时 并不存在。你可以在Dart 文档中阅读有关此构造的更多信息:

我们先稍微绕个弯,学习更多关于扩展类型 的知识,然后再带着这些新知识回到你的 JavaScript 工作中。

Dart 关于扩展类型的文档展示了这样一个示例,它将 int 类型的接口范围缩小,只允许进行一个操作:

dart 复制代码
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 唯一能做的操作


💡 关于扩展类型的构造函数

扩展类型的构造函数 不像常规类型那样作为成员定义,因为常规类型可能有多个构造函数,因为构造 对它们来说是实际的工作,我们可能希望以不同的方式完成。

另一方面,对于扩展类型来说,构造只是编译时的一种包装 ,它不会转换成任何实际的运行时操作,所以它总是只有一个构造函数 。因此,将其作为成员来定义就没有意义了,所以它们的语法是将构造函数 (int id) 直接放在类型名称之后。


🧐 那么,这对我们的例子有什么用呢?

(即如何将扩展类型应用于我们的 JavaScript 互操作场景?)

dart 复制代码
extension type NormalizedLandmark._(JSObject _) implements JSObject {  
external num get x;  
external num get y;  
}

在我们的扩展类型中:

  • 我们包装了一个 JSObject 并立即实现了 JSObject 接口 。这意味着我们没有剥离 该接口中的任何内容,而只是添加 了新功能。我们需要这样做是因为我们很快就会有 JSArray<NormalizedLandmark>,而 JSArray 只能包含 JSObject 及其子类
  • 我们使用 _ 作为名称,因为与 ID 的示例不同,我们没有将任何功能委托给我们包装的对象,因此不需要一个名称。
  • 我们使用私有构造函数 ,这是由于 ._ 的存在。

因此,这个包装器永远不能像 我们创建 IdNumber 那样(如 final safeId = IdNumber(42);)来创建。

相反,我们只能使用 as 关键字 对其进行强制转换 (cast)

我们把 getter 标记为 external。这意味着"它们已经在 JavaScript 中存在,可以直接工作"。

当我们使用扩展类型 来表示来自外部 JavaScript 或 WASM 库的对象时,它被称为 "互操作类型"(interop type) ,源自 inter-operation(互操作)


✍️ 定义互操作类型 (Defining the interop types)

我们需要更多的互操作类型来从 MediaPipe 库中创建 Landmarker 对象 、调用它的方法并从结果对象中获取数据。

这些类型可以通过查看 MediaPipe 的 TypeScript 源代码手动编写出来:

Dart 中的互操作类型有可能可以从 TypeScript 源码生成,但我还没有探索过这一点。手动实践一段时间是有益的。

以下是我从 TypeScript 中提取出来的部分,仅包含我们将实际使用的方法和属性

detect 函数的结果是:

dart 复制代码
extension type PoseLandmarkerResult._(JSObject _) implements JSObject {  
external JSArray<JSArray<NormalizedLandmark>> get landmarks;  
}

地标检测器 (The landmarker):

dart 复制代码
extension type PoseLandmarker._(JSObject _) implements JSObject {
  external JSPromise<PoseLandmarker> createFromOptions(
    WasmFileset fileset,
    PoseLandmarkerOptions options,
  );

  external void detect(HTMLImageElement img, JSFunction callback);
}

创建地标检测器的选项:

dart 复制代码
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;
}

PostLandmarkerOptions 中的基础选项:

dart 复制代码
extension type BaseOptions._(JSObject _) implements JSObject {  
    external BaseOptions({String modelAssetPath});  
  
    external String get modelAssetPath;  
}

WasmFileset,不管它是什么:

dart 复制代码
extension type WasmFileset._(JSObject _) implements JSObject {}

Fileset resolver:

dart 复制代码
extension type FilesetResolver._(JSObject _) implements JSObject {  
    external JSPromise<WasmFileset> forVisionTasks(String basePath);  
}

MediaPipe 模块的根对象:

dart 复制代码
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;
}

⚙️ 模型初始化

让我们继续编写设置插件的函数,并初始化模型

dart 复制代码
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(轻量)版本:

ai.google.dev/edge/mediap...

由于模型是在不同实现之间共享 的,最好将它们放入一个共享 package 中。flutter_mediapipe_vision_platform_interface 是最合适的,尽管从技术上讲它不属于接口范畴,但所有实现都已经依赖于它。

无论如何,当这个函数执行完毕时,我们就把 Landmarker 对象存储在了字段变量 _landmarker 中。


🧍 检测姿势 (Detecting poses)

这就是执行实际工作的方法

dart 复制代码
@override  
Future<PoseLandmarkerResult> detect(Uint8List bytes) async {  
final el = await _createImageFromBytes(bytes);  
// ...  
}

我们首先使用字节数据创建一个 HTMLImageElement 元素,因为这是 MediaPipe 的 detect 函数实际接受的输入类型。我们这样做:

dart 复制代码
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.onerror = () {
    web.URL.revokeObjectURL(imageUrl);
    completer.completeError('Cannot load the image.');
  }.toJS;

  el.src = imageUrl;
  await completer.future;
  return el;
}

💾 创建 Blob 对象

JavaScript 的 Blob (Binary Large Object,二进制大对象)构造函数接受一个二维字节数组 。因此,我们首先对 Uint8List 调用 .toJS,将其转换为一个 JavaScript 数组。许多 Dart 类型都有这个 getter 方法,用于生成可以传递给 JavaScript 函数的数据。接着,我们将这个数组包装到另一个列表中,并也将其转换为 JavaScript 数组。

然后,我们通过读取前几个字节来确定图像类型,这里我将跳过 _detectImageFormat 函数的细节。


🔗 生成 Blob URL

接下来,我们需要生成一个 URL 来设置给我们的 img 对象,因为这是将图像放入 HTML 元素的唯一方式

这里涉及到一个概念叫做 Blob URL 。我们基本上是在告诉浏览器:"嘿,我们需要在 img 元素中显示这些字节。请给我们一个指向它们的虚拟 URL。 "

浏览器随后会将这些字节存储到某个内部表中,并生成一个看起来像这样的 URL:

blob:http://localhost:40000/fd108f07-5e55-43d1-b5cd-691b973c03d6

这个 URL 是当前浏览器会话私有的,可以用于获取图像。有趣的是,你甚至可以在另一个标签页中打开它:

总之,我们创建了一个 img 元素 ,并将其 src 属性设置为那个 URL。现在,我们需要等待它加载完成 。为此,我们需要设置以下这些监听器

js 复制代码
el.onload = () {  
web.URL.revokeObjectURL(imageUrl);  
completer.complete();  
}.toJS;  
el.onerror = () {  
web.URL.revokeObjectURL(imageUrl);  
completer.completeError('Cannot load the image.');  
}.toJS;

这两个监听器(onloadonerror)都会使 Completer 完成 ,这样函数就可以返回准备就绪的 img 元素 ,或者因错误而中断

它们也都清理 (dispose)了 URL,这样它就不会浪费浏览器的内存。毕竟,我们将在每一帧都执行这个操作。

请注意,当我们向任何 JavaScript 例程传递 Dart 函数时,我们需要使用 .toJS getter 将其转换为常规的 JavaScript 函数

当我们拿到 img 元素后,就可以继续进行 detect 函数的操作了:

dart 复制代码
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;
  }

🔄 处理异步检测结果

请注意,JavaScript 的 detect 函数并不会直接返回结果 。相反,我们会向它传递一个回调函数(callback),数据准备好后会通过这个回调函数被调用。

这种设计允许该函数在回调返回时释放资源 ,这有可能改进垃圾回收机制 (garbage collection) 。但在实践中,我注意到该对象在回调完成后仍然存在,不过我们不能依赖这种现象。我们必须将 JavaScript 返回的结果对象转换 成我们定义的那个平台无关的对象(即我们在第二个 package 中定义的类型)。

至此,所有 package 的代码就完成了!


🔗 整合 Packages (Tying the packages together)

Web 实现 package 需要在其 pubspec.yaml 文件中声明 它包含一个插件实现,这样 Flutter 才知道启动时应该调用哪个方法来置换(swap in)这个实现:

yaml 复制代码
flutter:
  plugin:
    platforms:
      web:
        pluginClass: FlutterMediapipeVisionWeb
        fileName: flutter_mediapipe_vision_web.dart

平台接口(platform interface)需要在其代码中声明 它的资源 (assets),这样在使用它的应用的最终构建中,这些资源才能被打包进去:

yaml 复制代码
flutter:
  assets:
    - assets/models/pose_landmarker_lite.task

面向用户的 package 需要正式认可(或"推荐使用")这个插件:

yaml 复制代码
flutter:
  plugin:
    platforms:
      web:
        default_package: flutter_mediapipe_vision_web

📱 应用

📹 显示摄像头视频 (Showing the camera video)

我们需要做的第一件事是在屏幕上显示来自摄像头 的视频流。让我们创建并初始化摄像头控制器 ,然后显示 CameraPreview 组件:

dart 复制代码
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),
        ),
      ),
    );
  }
}

📹 最小化应用示例

这是一个在屏幕上显示摄像头视频的最小化应用 。它并不完美,因为它会在显示任何内容之前阻塞 (block),直到获得使用摄像头的权限 ,并且如果访问被拒绝,它也不会重试。但它能完成工作:

📸 捕获和分析静止图像 (Capturing and analyzing stills)

让我们创建一个控制器来进行识别:

dart 复制代码
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();
  }
}

🏃 启动与循环 (Starting the Recognition)

一旦调用了 start() 函数,它就会永远运行下去 。这对于移动设备 来说不太理想(因为应用可能会被系统从内存中清除),但对于这个最小化的 Web 版本来说是可以接受的。

在循环中,我们使用 cameraController.takePicture() 捕获一帧图像,然后将其作为字节数据 传递给我们的插件,并获取经过分析的结果

现在,让我们在 main() 函数中创建这个控制器:

dart 复制代码
late InferenceController inferenceController; // CHANGED

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await FlutterMediapipeVision.ensureInitialized();

  final cameraController = CameraController(
    (await availableCameras()).first,
    ResolutionPreset.low,
    enableAudio: false,
  );
  await cameraController.initialize();

  // NEW:
  inferenceController = InferenceController(cameraController: cameraController);
  unawaited(inferenceController.start());

  runApp(const MyApp());
}

🦴 显示骨架覆盖层 (Showing the skeleton overlay)

让我们创建一个 CameraOverlayWidget 组件来完成这项工作:

dart 复制代码
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,
          );
        }
    );
  }
}

👂 监听与重绘 (Listening and Rebuilding)

这个组件监听 来自控制器的通知 ,并在每次收到通知时进行重建(rebuilds)。

请注意,我们在 builder 函数的外部 创建了 CameraPreview 组件,并将其作为 child 传递给 ListenableBuilder 。这样做可以将 CameraPreview 排除在重建过程之外,从而使性能稍微快一些。


🎨 自定义绘制 (Custom Painting)

CustomPaint 组件使用 foregroundPainterchild 组件的上方覆盖一层进行绘制。

现在,让我们来创建这个 CameraOverlayPainter

dart 复制代码
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.
}

🎨 骨架绘制逻辑

这个类(CameraOverlayPainter)只是从识别结果中选取所有兴趣点 (points of interest),并用线条连接相邻的点

由于坐标是从 0 到 1归一化值 ,所以它将这些坐标乘以 size ------一个持有当前组件尺寸的参数。因为这个覆盖层(overlay)与摄像头预览是相同尺寸的,所以一切都恰到好处。


🎉 最终成果

这最终为我们实现了我们想要的效果:

🚀 再次展示已部署的演示 (Deployed Demo Once Again)

这是已部署的演示应用链接:

alexeyinkin.github.io/flutter-med...


🌐 浏览器兼容性 (Browser compatibility)

  • Chrome 浏览器中运行良好。
  • Firefox 144 中,它由于一个 camera package 的 bug 而崩溃,我很快会定位并提交这个问题。
  • Safari 浏览器 中,它就是无法运行,没有任何明显的症状或错误信息。如果你知道问题出在哪里,请告诉我。
相关推荐
saadiya~2 小时前
基于 Vue3 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)
前端·vue3·大华视频相机前端播放
LSL666_2 小时前
spring多配置文件
java·服务器·前端·spring
万少2 小时前
HarmonyOS preview 预览文件 Kit 的入门讲解
前端
IT_陈寒2 小时前
JavaScript 性能优化实战:我从 V8 源码中学到的 7 个关键技巧
前端·人工智能·后端
jenchoi4133 小时前
软件供应链npm/pypi投毒预警情报【2025-11-09】
前端·安全·web安全·网络安全·npm·node.js
艾小码3 小时前
别再只会用默认插槽了!Vue插槽这些高级用法让你的组件更强大
前端·javascript·vue.js
JaguarJack3 小时前
CSS 也要支持 if 了 !!!CSS if() 函数来了!
前端·css
恋猫de小郭3 小时前
Flutter 3.38 发布,快来看看有什么更新吧
android·前端·flutter
wuk9989 小时前
实现ROS系统的Websocket传输,向Web应用推送sensor_msgs::Image数据
前端·websocket·网络协议