Hello World! (iOS)

简介

本 Hello World! 教程使用 MediaPipe 框架来开发一个在 iOS 上运行 MediaPipe 图表的 iOS 应用。

您将构建的模型

一款简单的相机应用,用于对 iOS 设备上的实时视频串流应用的实时 Sobel 边缘检测。

edge_detection_ios_gpu_gif

初始设置

  1. 在系统上安装 MediaPipe Framework,如需了解详情,请参阅框架安装指南
  2. 设置您的 iOS 设备以进行开发。
  3. 在系统上设置 Bazel 以构建和部署 iOS 应用。

边缘检测图表

我们将使用以下图表 edge_detection_mobile_gpu.pbtxt

# MediaPipe graph that performs GPU Sobel edge detection on a live video stream.
# Used in the examples
# mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic:helloworld
# and mediapipe/examples/ios/helloworld.

# Images coming into and out of the graph.
input_stream: "input_video"
output_stream: "output_video"

# Converts RGB images into luminance images, still stored in RGB format.
node: {
  calculator: "LuminanceCalculator"
  input_stream: "input_video"
  output_stream: "luma_video"
}

# Applies the Sobel filter to luminance images stored in RGB format.
node: {
  calculator: "SobelEdgesCalculator"
  input_stream: "luma_video"
  output_stream: "output_video"
}

图表的可视化效果如下所示:

edge_detection_mobile_gpu

此图包含一个名为 input_video 的输入流,该输入流对应于设备摄像头将提供的所有传入帧。

图中的第一个节点 LuminanceCalculator 接受单个数据包(图像帧),并使用 OpenGL 着色器应用亮度变化。生成的图片帧会发送到 luma_video 输出流。

第二个节点 SobelEdgesCalculator 会对 luma_video 流中的传入数据包应用边缘检测,并输出 output_video 输出流。

我们的 iOS 应用将显示 output_video 流的输出图片帧。

初始最低应用设置

我们首先从一个简单的 iOS 应用开始,并演示如何使用 bazel 来构建该应用。

首先,通过“File”>“New”>“Single View App”创建一个 XCode 项目。

将产品名称设置为“HelloWorld”,并使用适当的组织标识符,例如 com.google.mediapipe。组织标识符以及产品名称都将是应用的 bundle_id,例如 com.google.mediapipe.HelloWorld

将语言设置为 Objective-C。

将项目保存到适当的位置。将其称为 $PROJECT_TEMPLATE_LOC。因此,您的项目位于 $PROJECT_TEMPLATE_LOC/HelloWorld 目录中。此目录将包含另一个名为 HelloWorld 的目录和一个 HelloWorld.xcodeproj 文件。

HelloWorld.xcodeproj 对本教程没有用,因为我们将使用 bazel 来构建 iOS 应用。下面列出了 $PROJECT_TEMPLATE_LOC/HelloWorld/HelloWorld 目录的内容:

  1. AppDelegate.hAppDelegate.m
  2. ViewController.hViewController.m
  3. main.m
  4. Info.plist
  5. Main.storyboardLaunch.storyboard
  6. Assets.xcassets 目录中。

将这些文件复制到名为 HelloWorld 的目录中,并放在可以访问 MediaPipe 框架源代码的位置。例如,我们在本教程中构建的应用的源代码位于 mediapipe/examples/ios/HelloWorld 中。在整个 Codelab 中,我们将此路径称为 $APPLICATION_PATH

$APPLICATION_PATH 中创建 BUILD 文件并添加以下构建规则:

MIN_IOS_VERSION = "11.0"

load(
    "@build_bazel_rules_apple//apple:ios.bzl",
    "ios_application",
)

ios_application(
    name = "HelloWorldApp",
    bundle_id = "com.google.mediapipe.HelloWorld",
    families = [
        "iphone",
        "ipad",
    ],
    infoplists = ["Info.plist"],
    minimum_os_version = MIN_IOS_VERSION,
    provisioning_profile = "//mediapipe/examples/ios:developer_provisioning_profile",
    deps = [":HelloWorldAppLibrary"],
)

objc_library(
    name = "HelloWorldAppLibrary",
    srcs = [
        "AppDelegate.m",
        "ViewController.m",
        "main.m",
    ],
    hdrs = [
        "AppDelegate.h",
        "ViewController.h",
    ],
    data = [
        "Base.lproj/LaunchScreen.storyboard",
        "Base.lproj/Main.storyboard",
    ],
    sdk_frameworks = [
        "UIKit",
    ],
    deps = [],
)

objc_library 规则会为 AppDelegateViewController 类、main.m 和应用故事板添加依赖项。模板化应用仅依赖于 UIKit SDK。

ios_application 规则使用生成的 HelloWorldAppLibrary Objective-C 库来构建 iOS 应用,以便在 iOS 设备上安装。

如需构建应用,请在终端中运行以下命令:

bazel build -c opt --config=ios_arm64 <$APPLICATION_PATH>:HelloWorldApp'

例如,如需在 mediapipe/examples/ios/helloworld 中构建 HelloWorldApp 应用,请使用以下命令:

bazel build -c opt --config=ios_arm64 mediapipe/examples/ios/helloworld:HelloWorldApp

然后,返回 XCode,依次打开“Window”>“Device and Simulators”,选择您的设备,并将上述命令生成的 .ipa 文件添加到您的设备。 本文档提供了有关设置和编译 iOS 框架应用的文档。

在设备上打开该应用。由于该区域是空的,因此应该显示一个空白的白色屏幕。

使用摄像头查看实时画面

在本教程中,我们将使用 MPPCameraInputSource 类从相机访问和抓取帧。此类使用 AVCaptureSession API 从相机获取帧。

但在使用此类之前,请更改 Info.plist 文件以支持在应用中使用相机。

ViewController.m 中,添加以下 import 行:

#import "mediapipe/objc/MPPCameraInputSource.h"

将以下代码添加到其实现块中,以创建一个对象 _cameraSource

@implementation ViewController {
  // Handles camera access via AVCaptureSession library.
  MPPCameraInputSource* _cameraSource;
}

将以下代码添加到 viewDidLoad()

-(void)viewDidLoad {
  [super viewDidLoad];

  _cameraSource = [[MPPCameraInputSource alloc] init];
  _cameraSource.sessionPreset = AVCaptureSessionPresetHigh;
  _cameraSource.cameraPosition = AVCaptureDevicePositionBack;
  // The frame's native format is rotated with respect to the portrait orientation.
  _cameraSource.orientation = AVCaptureVideoOrientationPortrait;
}

该代码会初始化 _cameraSource、设置拍摄会话预设以及要使用的相机。

我们需要将帧从 _cameraSource 发送到应用 ViewController 中才能显示它们。MPPCameraInputSourceMPPInputSource 的子类,用于为其代理提供协议,即 MPPInputSourceDelegate。因此,我们的应用 ViewController 可以作为 _cameraSource 的委托。

相应地更新 ViewController 的接口定义:

@interface ViewController () <MPPInputSourceDelegate>

为了处理相机设置和传入的帧,我们应使用与主队列不同的队列。将以下代码添加到 ViewController 的实现块中:

// Process camera frames on this queue.
dispatch_queue_t _videoQueue;

viewDidLoad() 中,初始化 _cameraSource 对象后添加以下行:

[_cameraSource setDelegate:self queue:_videoQueue];

在设置 _cameraSource 对象之前,添加以下代码以初始化队列:

dispatch_queue_attr_t qosAttribute = dispatch_queue_attr_make_with_qos_class(
      DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, /*relative_priority=*/0);
_videoQueue = dispatch_queue_create(kVideoQueueLabel, qosAttribute);

我们将使用优先级为 QOS_CLASS_USER_INTERACTIVE 的串行队列来处理相机帧。

在文件顶部的头文件导入后、ViewController 的接口/实现之前,添加以下代码行:

static const char* kVideoQueueLabel = "com.google.mediapipe.example.videoQueue";

在实现 MPPInputSourceDelegate 协议中的任何方法之前,我们都必须先设置一种显示相机帧的方式。Mediapipe 框架提供了另一个名为 MPPLayerRenderer 的实用程序,用于在屏幕上显示图片。此实用程序可用于显示 CVPixelBufferRef 对象,这些对象是 MPPCameraInputSource 向其委托提供的图片类型。

ViewController.m 中,添加以下 import 行:

#import "mediapipe/objc/MPPLayerRenderer.h"

为了显示屏幕图像,我们需要向 ViewController 添加一个名为 _liveView 的新 UIView 对象。

将以下代码行添加到 ViewController 的实现块中:

// Display the camera preview frames.
IBOutlet UIView* _liveView;
// Render frames in a layer.
MPPLayerRenderer* _renderer;

转到 Main.storyboard,将对象库中的 UIView 对象添加到 ViewController 类的 View。将此视图中的一个引用输出口添加到您刚刚添加到 ViewController 类的 _liveView 对象中。调整视图大小,使其居中并覆盖整个应用屏幕。

返回 ViewController.m,并将以下代码添加到 viewDidLoad() 以初始化 _renderer 对象:

_renderer = [[MPPLayerRenderer alloc] init];
_renderer.layer.frame = _liveView.layer.bounds;
[_liveView.layer addSublayer:_renderer.layer];
_renderer.frameScaleMode = MPPFrameScaleModeFillAndCrop;

为了从相机获取帧,我们将实现以下方法:

// Must be invoked on _videoQueue.
-   (void)processVideoFrame:(CVPixelBufferRef)imageBuffer
                timestamp:(CMTime)timestamp
               fromSource:(MPPInputSource*)source {
  if (source != _cameraSource) {
    NSLog(@"Unknown source: %@", source);
    return;
  }
  // Display the captured image on the screen.
  CFRetain(imageBuffer);
  dispatch_async(dispatch_get_main_queue(), ^{
    [_renderer renderPixelBuffer:imageBuffer];
    CFRelease(imageBuffer);
  });
}

这是 MPPInputSource 的委托方法。我们首先检查从正确的来源(即 _cameraSource)获取帧。然后,我们在主队列中显示通过 _renderer 从相机接收的帧。

现在,我们需要在用于显示帧的视图即将显示时立即启动相机。为此,我们将实现 viewWillAppear:(BOOL)animated 函数:

-(void)viewWillAppear:(BOOL)animated {
  [super viewWillAppear:animated];
}

在开始运行相机之前,我们需要获得用户的访问权限。MPPCameraInputSource 提供了一个函数 requestCameraAccessWithCompletionHandler:(void (^_Nullable)(BOOL granted))handler,用于请求相机访问权限,并在用户响应时执行一些工作。将以下代码添加到 viewWillAppear:animated

[_cameraSource requestCameraAccessWithCompletionHandler:^void(BOOL granted) {
  if (granted) {
    dispatch_async(_videoQueue, ^{
      [_cameraSource start];
    });
  }
}];

在构建应用之前,请将以下依赖项添加到 BUILD 文件:

sdk_frameworks = [
    "AVFoundation",
    "CoreGraphics",
    "CoreMedia",
],
deps = [
    "//mediapipe/objc:mediapipe_framework_ios",
    "//mediapipe/objc:mediapipe_input_sources_ios",
    "//mediapipe/objc:mediapipe_layer_renderer",
],

现在,在您的 iOS 设备上构建并运行应用。接受摄像头权限后,您应该会看到摄像头实时画面画面 Feed。

现在,我们可以在 MediaPipe 图中使用相机帧了。

在 iOS 中使用 MediaPipe 图

添加相关依赖项

我们已经添加了 MediaPipe 框架代码的依赖项,其中包含用于使用 MediaPipe 图的 iOS API。如需使用 MediaPipe 图,我们需要为要在应用中使用的图添加依赖项。将以下代码行添加到 BUILD 文件的 data 列表中:

"//mediapipe/graphs/edge_detection:mobile_gpu_binary_graph",

现在,在 BUILD 文件的 deps 字段中,向此图表中使用的计算器添加依赖项:

"//mediapipe/graphs/edge_detection:mobile_calculators",

最后,将文件 ViewController.m 重命名为 ViewController.mm 以支持 Objective-C++。

使用ViewController中的图表

ViewController.m 中,添加以下 import 行:

#import "mediapipe/objc/MPPGraph.h"

使用图表名称、输入流和输出流声明静态常量:

static NSString* const kGraphName = @"mobile_gpu";

static const char* kInputStream = "input_video";
static const char* kOutputStream = "output_video";

将以下属性添加到 ViewController 的接口:

// The MediaPipe graph currently in use. Initialized in viewDidLoad, started in viewWillAppear: and
// sent video frames on _videoQueue.
@property(nonatomic) MPPGraph* mediapipeGraph;

如上面的注释所述,我们首先在 viewDidLoad 中初始化此图。为此,我们需要使用以下函数从 .pbtxt 文件加载图表:

+   (MPPGraph*)loadGraphFromResource:(NSString*)resource {
  // Load the graph config resource.
  NSError* configLoadError = nil;
  NSBundle* bundle = [NSBundle bundleForClass:[self class]];
  if (!resource || resource.length == 0) {
    return nil;
  }
  NSURL* graphURL = [bundle URLForResource:resource withExtension:@"binarypb"];
  NSData* data = [NSData dataWithContentsOfURL:graphURL options:0 error:&configLoadError];
  if (!data) {
    NSLog(@"Failed to load MediaPipe graph config: %@", configLoadError);
    return nil;
  }

  // Parse the graph config resource into mediapipe::CalculatorGraphConfig proto object.
  mediapipe::CalculatorGraphConfig config;
  config.ParseFromArray(data.bytes, data.length);

  // Create MediaPipe graph with mediapipe::CalculatorGraphConfig proto object.
  MPPGraph* newGraph = [[MPPGraph alloc] initWithGraphConfig:config];
  [newGraph addFrameOutputStream:kOutputStream outputPacketType:MPPPacketTypePixelBuffer];
  return newGraph;
}

使用此函数初始化 viewDidLoad 中的图表,如下所示:

self.mediapipeGraph = [[self class] loadGraphFromResource:kGraphName];

图表应将相机帧的处理结果发送回 ViewController。初始化图表后添加以下行,以将 ViewController 设置为 mediapipeGraph 对象的委托:

self.mediapipeGraph.delegate = self;

为避免在处理实时视频 Feed 中的帧时发生内存争用,请添加以下代码行:

// Set maxFramesInFlight to a small value to avoid memory contention for real-time processing.
self.mediapipeGraph.maxFramesInFlight = 2;

现在,在用户授予在应用中使用相机的权限后启动图表:

[_cameraSource requestCameraAccessWithCompletionHandler:^void(BOOL granted) {
  if (granted) {
    // Start running self.mediapipeGraph.
    NSError* error;
    if (![self.mediapipeGraph startWithError:&error]) {
      NSLog(@"Failed to start graph: %@", error);
    }
    else if (![self.mediapipeGraph waitUntilIdleWithError:&error]) {
      NSLog(@"Failed to complete graph initial run: %@", error);
    }

    dispatch_async(_videoQueue, ^{
      [_cameraSource start];
    });
  }
}];

之前,当我们在 processVideoFrame 函数中接收来自相机的帧时,我们使用 _renderer_liveView 中显示这些帧。现在,我们需要将这些帧发送到图表并渲染结果。请修改此函数的实现以执行以下操作:

-   (void)processVideoFrame:(CVPixelBufferRef)imageBuffer
                timestamp:(CMTime)timestamp
               fromSource:(MPPInputSource*)source {
  if (source != _cameraSource) {
    NSLog(@"Unknown source: %@", source);
    return;
  }
  [self.mediapipeGraph sendPixelBuffer:imageBuffer
                            intoStream:kInputStream
                            packetType:MPPPacketTypePixelBuffer];
}

我们将 imageBuffer 作为 MPPPacketTypePixelBuffer 类型的数据包发送到 self.mediapipeGraph,并发送到输入流 kInputStream(即“input_video”)。

该图将使用此输入数据包运行,并在 kOutputStream 中输出结果,即“output_video”。我们可以实现以下委托方法来接收此输出流上的数据包,并将其显示在屏幕上:

-   (void)mediapipeGraph:(MPPGraph*)graph
   didOutputPixelBuffer:(CVPixelBufferRef)pixelBuffer
             fromStream:(const std::string&)streamName {
  if (streamName == kOutputStream) {
    // Display the captured image on the screen.
    CVPixelBufferRetain(pixelBuffer);
    dispatch_async(dispatch_get_main_queue(), ^{
      [_renderer renderPixelBuffer:pixelBuffer];
      CVPixelBufferRelease(pixelBuffer);
    });
  }
}

使用 MPPGraphDelegate 更新 ViewController 的接口定义:

@interface ViewController () <MPPGraphDelegate, MPPInputSourceDelegate>

仅此而已!在您的 iOS 设备上构建并运行应用。您应该会看到对实时视频 Feed 运行边缘检测图的结果。恭喜!

edge_detection_ios_gpu_gif

请注意,iOS 示例现在使用的是common模板应用。本教程中的代码用于common模板应用。helloworld 应用具有边缘检测图的相应 BUILD 文件依赖项。