iOS 上的「Hello World」!

簡介

本 Hello World! 教學課程使用 MediaPipe Framework,開發在 iOS 上執行 MediaPipe 圖表的 iOS 應用程式。

建構內容

簡單的相機應用程式,用於在 iOS 裝置的即時影像串流上進行即時 Sobel 邊緣偵測。

edge_detection_ios_gpu_gif

設定

  1. 在系統上安裝 MediaPipe 架構,詳情請參閱架構安裝指南
  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」,並使用適當的機構 ID,例如 com.google.mediapipe。機構 ID 與產品名稱將會是應用程式的 bundle_id,例如 com.google.mediapipe.HelloWorld

將語言設為 Objective-C。

將專案儲存至合適的位置。我們將其命名為 $PROJECT_TEMPLATE_LOC。因此專案會位於 $PROJECT_TEMPLATE_LOC/HelloWorld 目錄中。這個目錄包含另一個名為 HelloWorld 的目錄和一個 HelloWorld.xcodeproj 檔案。

我們將使用 Bazel 建構 iOS 應用程式,因此本教學課程不會使用 HelloWorld.xcodeproj$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 Framework 原始碼的位置。例如,本教學課程將建構的應用程式原始碼位於 mediapipe/examples/ios/HelloWorld。在本程式碼研究室中,我們會將這個路徑稱為 $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 emulators],選取您的裝置,然後將上述指令產生的 .ipa 檔案新增至您的裝置。以下是設定及編譯 iOS 架構應用程式的說明文件。

在裝置上開啟應用程式。由於它沒有內容,因此應該顯示空白的白色畫面。

使用攝影機查看即時監控畫面

在這個教學課程中,我們會使用 MPPCameraInputSource 類別存取並擷取相機中的影格。此類別會使用 AVCaptureSession API 從相機取得影格。

但是,在使用這個類別之前,請變更 Info.plist 檔案,以便在應用程式中支援相機用量。

ViewController.m 中,新增下列匯入行:

#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 "mediapipe/objc/MPPLayerRenderer.h"

為了顯示畫面的圖片,我們需要在 ViewController 中新增名為 _liveViewUIView 物件。

將下列程式碼新增至 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 裝置上建構並執行應用程式。接受相機權限後,您應該會看到攝影機的即時監控畫面。

現在可以在 MediaPipe 圖表中使用相機影格。

在 iOS 中使用 MediaPipe 圖表

新增相關的依附元件

我們已新增 MediaPipe 架構程式碼的依附元件,其中包含使用 iOS API 的 iOS API,以便使用 MediaPipe 圖表。要使用 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 "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;

如要避免在處理即時影像動態饋給中的影格時發生記憶體爭用情形,請新增下列程式碼:

// 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];
}

我們會將 imageBufferMPPPacketTypePixelBuffer 類型的封包傳送至 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 裝置上建構並執行應用程式。您應該在即時視訊動態饋給中看見執行邊緣偵測圖表的結果。恭喜!

edge_detection_ios_gpu_gif

請注意,iOS 範例現在會使用 common 範本應用程式。本教學課程中的程式碼用於 common 範本應用程式。helloworld 應用程式擁有邊緣偵測圖適用的 BUILD 檔案依附元件。