身体检测

一个 Flutter 插件,可在 iOS/Android 平台上使用 MLKit,通过姿势检测和自拍分割 API,为静态图像和实时摄像头流启用身体姿势和遮罩检测。在使用实时摄像头检测时,它在原生端运行摄像头图像采集和检测器,这比单独使用 Flutter 摄像头插件更快。

功能

  • 身体姿势检测。
  • 身体遮罩检测(自拍分割)。
  • 在单个图像或实时摄像头馈送上运行。
  • 摄像头图像采集和检测器在原生端一起运行,这比为两者使用单独的插件并在它们之间传递数据更快。
  • 使用 MLKit 进行检测,因此共享其优缺点。

安装

iOS

最低要求的 iOS 版本为 10.0,因此如果针对较低的目标进行编译,请确保在调用此库的函数之前以编程方式检查 iOS 版本。

为了能够使用基于摄像头的检测,您需要使用 Xcode 在 ios/Runner/Info.plist 文件中添加一行

  • 隐私 - 相机使用说明 并设置使用说明消息。

如果以纯文本编辑文件,请添加以下内容

<key>NSCameraUsageDescription</key>
<string>Can I use the camera please?</string>

Android

android/app/build.gradle 文件中将最低 Android SDK 版本设置为 21(或更高)。

android {
    defaultConfig {
        minSdkVersion 21
    }
}

要使用摄像头,您需要将 uses-permission 声明添加到 android/app/src/main/AndroidManifest.xml manifest 文件中

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="YOUR_PACKAGE_NAME">
  <uses-permission android:name="android.permission.CAMERA"/>
  ...

用法

该插件有两种模式,您可以对单个图像进行姿势或身体遮罩检测,或者启动摄像头流并在其运行时启用或禁用任一检测器。

单张图像检测

最简单的用例是在单个图像上检测姿势或身体遮罩。该插件接受 PNG 编码的图像并将其发送到原生端,在那里它使用 Google MLKit 的 Vision API 来完成繁重的工作。之后,结果会传回 Flutter。

import 'package:body_detection/body_detection.dart';
import 'package:body_detection/png_image.dart';

PngImage pngImage = PngImage.from(bytes, width: width, height: height);
final pose = await BodyDetection.detectPose(image: pngImage);
final bodyMask = await BodyDetection.detectBodyMask(image: pngImage);

该插件为 Flutter 的 Image widget 提供了一个扩展,可将其转换为 PNG 格式,因此您可以使用该 widget 支持的任何图像源作为输入:本地资源、网络、内存等。

import 'package:body_detection/body_detection.dart';
import 'package:body_detection/png_image.dart';
import 'package:flutter/widgets.dart';

void detectImagePose(Image source) async {
  PngImage? pngImage = await source.toPngImage();
  if (pngImage == null) return;
  final pose = await BodyDetection.detectPose(image: pngImage);
  ...
}

如果您出于布局目的需要编码的 PNG 图像的大小(例如图像的纵横比),您可以从返回的 PngImage 对象中获取。

import 'package:body_detection/png_image.dart';
import 'package:flutter/widgets.dart';

Image source;
PngImage? pngImage = await source.toPngImage();
final imageSize = pngImage != null
  ? Size(pngImage!.width.toDouble(), pngImage!.height.toDouble())
  : Size.zero;

BodyDetection.detectPose 调用返回的 Pose 对象包含 MLKit 检测器返回的地标列表。每个地标的结构都遵循 MLKit 建立的结构。

final pose = await BodyDetection.detectPose(image: pngImage);
for (final landmark in pose!.landmarks) {
  // The detector estimate of how likely it is that the landmark is within the image's frame.
  double inFrameLikelihood = landmark.inFrameLikelihood;

  // Position of the landmark in image plane coordinates with z value estimated by the detector.
  Point3d position = landmark.position;

  // One of the 33 detectable body landmarks.
  PoseLandmarkType type = landmark.type;
}

您可以使用自定义绘制器绘制姿势

class PosePainter extends CustomPainter {
  PosePainter({
    required this.pose,
    required this.imageSize,
  });

  final Pose pose;
  final Size imageSize;
  final circlePaint = Paint()..color = const Color.fromRGBO(0, 255, 0, 0.8);
  final linePaint = Paint()
    ..color = const Color.fromRGBO(255, 0, 0, 0.8)
    ..strokeWidth = 2;

  @override
  void paint(Canvas canvas, Size size) {
    final double hRatio =
        imageSize.width == 0 ? 1 : size.width / imageSize.width;
    final double vRatio =
        imageSize.height == 0 ? 1 : size.height / imageSize.height;

    offsetForPart(PoseLandmark part) =>
        Offset(part.position.x * hRatio, part.position.y * vRatio);

    for (final part in pose.landmarks) {
      // Draw a circular indicator for the landmark.
      canvas.drawCircle(offsetForPart(part), 5, circlePaint);

      // Draw text label for the landmark.
      TextSpan span = TextSpan(
        text: part.type.toString().substring(16),
        style: const TextStyle(
          color: Color.fromRGBO(0, 128, 255, 1),
          fontSize: 10,
        ),
      );
      TextPainter tp = TextPainter(text: span, textAlign: TextAlign.left);
      tp.textDirection = TextDirection.ltr;
      tp.layout();
      tp.paint(canvas, offsetForPart(part));
    }

    // Draw connections between the landmarks.
    final landmarksByType = {for (final it in pose.landmarks) it.type: it};
    for (final connection in connections) {
      final point1 = offsetForPart(landmarksByType[connection[0]]!);
      final point2 = offsetForPart(landmarksByType[connection[1]]!);
      canvas.drawLine(point1, point2, linePaint);
    }
  }

  ...
}

并在您的 widget 树中使用它

@override
Widget build(BuildContext context) {
  // Use ClipRect so that custom painter doesn't draw outside of the widget area.
  return ClipRect(
    child: CustomPaint(
      child: _sourceImage,
      foregroundPainter: PosePainter(
        pose: _detectedPose,
        imageSize: _imageSize,
      ),
    ),
  );
}

有关详细信息,请参阅 Google MLKit 姿势检测 API 文档

检测到的身体遮罩以双精度值缓冲区返回,长度为 width * height,表示特定像素覆盖的区域是识别为身体的置信度。身体遮罩的大小可能与输入图像的大小不同,因为这可以加快计算速度,并且在较慢的设备上也能以可接受的性能运行。

要显示遮罩,您可以例如使用缓冲区值作为 alpha 分量,将缓冲区解码为 Dart 图像。

final mask = await BodyDetection.detectBodyMask(image: pngImage);
final bytes = mask.buffer
    .expand((it) => [0, 0, 0, (it * 255).toInt()])
    .toList();
ui.decodeImageFromPixels(Uint8List.fromList(bytes), mask.width, mask.height, ui.PixelFormat.rgba8888, (image) {
  // Do something with the image, for example set it as a widget's state field so you can pass it to a custom painter for drawing in the build method.
});

然后,例如,要绘制透明的蓝色叠加层,您可以使用如下自定义绘制器:

class MaskPainter extends CustomPainter {
  MaskPainter({
    required this.mask,
  });

  final ui.Image mask;
  final maskPaint = Paint()
    ..colorFilter = const ColorFilter.mode(
        Color.fromRGBO(0, 0, 255, 0.5), BlendMode.srcOut);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawImageRect(
        mask,
        Rect.fromLTWH(0, 0, mask.width.toDouble(), mask.height.toDouble()),
        Rect.fromLTWH(0, 0, size.width, size.height),
        maskPaint);
  }
}

并与原始图像一起在您的 widget 树中使用

@override
Widget build(BuildContext context) {
  return CustomPaint(
    child: _sourceImage,
    foregroundPainter: MaskPainter(mask: _maskImage),
  );

有关详细信息,请参阅 Google MLKit 自拍分割 API 文档

您可以在 插件的存储库 中查看完整的示例。

摄像头馈送检测

除了单张图像检测外,该插件还支持从摄像头馈送中实时检测姿势和/或身体遮罩。摄像头图像采集和检测都在原生端运行,因此我们消除了如果为每个单独的 Flutter 插件使用单独的插件所需要的数据序列化成本。

要启动和停止摄像头流,请使用以下方法,并为摄像头帧或检测结果准备就绪时传入回调。

await BodyDetection.startCameraStream(
  onFrameAvailable: (ImageResult image) {},
  onPoseAvailable: (Pose? pose) {},
  onMaskAvailable: (BodyMask? mask) {},
);
await BodyDetection.stopCameraStream();

检测器默认禁用。要启用或禁用特定检测器,请使用以下方法。

await BodyDetection.enablePoseDetection();
await BodyDetection.disablePoseDetection();

await BodyDetection.enableBodyMaskDetection();
await BodyDetection.disableBodyMaskDetection();

您可以在启动摄像头流之前运行它们,也可以在流已运行时运行它们。

要显示摄像头图像,您可以创建一个字节图像。

void _handleCameraImage(ImageResult result) {
  // Ignore callback if navigated out of the page.
  if (!mounted) return;

  // To avoid a memory leak issue.
  // https://github.com/flutter/flutter/issues/60160
  PaintingBinding.instance?.imageCache?.clear();
  PaintingBinding.instance?.imageCache?.clearLiveImages();

  final image = Image.memory(
    result.bytes,
    gaplessPlayback: true,
    fit: BoxFit.contain,
  );

  setState(() {
    _cameraImage = image;
    _imageSize = result.size;
  });
}

ImageResult 的 bytes 字段包含来自摄像头的 JPEG 编码帧,Image.memory() 工厂构造函数接受其作为输入。

示例应用

运行姿势检测

Demo

运行身体遮罩检测

Demo

未来发展

该库在将图像从摄像头编码并传递给 Flutter 时对其进行序列化。这使得可以轻松访问图像数据,从而可以进行额外的处理、将帧保存到存储或通过网络发送它们,但这也增加了开销,如果它仅传递一个纹理 ID,那么可以使用与摄像头插件相同的方式使用 Texture widget 进行显示,则可以避免这种开销。

它尚未提供任何配置选项,并依赖于默认设置(“准确”姿势检测模型、自拍分割的原始大小遮罩、使用第一个可用的前置摄像头、默认摄像头图像大小、JPEG 编码质量设置为 60)。让用户选择这些设置以匹配他们的用例将是很好的。

异常处理尚未统一,并且可以做得更好。

通知

此库尚未经过实战测试。由于它是早期版本,其功能和 API 可能会发生变化。如果您遇到问题或想请求功能,请随时在项目的 GitHub 页面上 打开一个 issue

MLKit 的姿势检测和自拍分割仍处于测试阶段,因此此软件也应被视为处于测试阶段。

GitHub

查看 Github