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

有一个名为 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),是因为如果 x 和 y 坐标在画面帧内,它们的值范围就在 0 到 1 之间。
如果图像被裁剪,并且模型认为某个特定的点在图像外部,那么这些坐标也可能小于零或大于 1,就像下面这个网络摄像头示例一样:

为什么不直接使用来自 dart:ui 的 Offset 类型呢?
这个库(指 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();
}
}
📌 关键点解析
这里涉及许多内容。
最重要的是,我们定义了两个业务逻辑函数 :ensureInitialized 和 detect。
接下来,_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 端,它是一个带有 x 和 y 属性的常规对象 ,在我们的 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 源代码手动编写出来:
- fileset_resolver.ts.template
landmark.d.ts- pose_landmarker.ts
pose_landmarker_options.d.tspose_landmarker_result.tstask_runner_options.d.tsvision_task_options.d.ts
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(轻量)版本:
由于模型是在不同实现之间共享 的,最好将它们放入一个共享 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;
这两个监听器(onload 和 onerror)都会使 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 组件使用 foregroundPainter 在 child 组件的上方覆盖一层进行绘制。
现在,让我们来创建这个 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 浏览器 中,它就是无法运行,没有任何明显的症状或错误信息。如果你知道问题出在哪里,请告诉我。