首页
/ MediaPipe iOS 实时视频管线实战:从零构建相机边缘检测应用

MediaPipe iOS 实时视频管线实战:从零构建相机边缘检测应用

2026-09-05 10:41:24作者:戚魁泉Nursing

本文基于仓库中的 Hello World! on iOS 教程,系统讲解如何在 iOS 设备上构建一个实时 Sobel 边缘检测相机应用。读完你将完整掌握 MediaPipe 在 iOS 端的接入方式:如何用 Bazel 构建安装 iOS App、如何通过 MPPCameraInputSource 获取相机帧、如何把帧送入 MPPGraph 图执行引擎并渲染输出,同时结合仓库中 helloworld 示例common 模板 的源码印证每一步的实际实现。

教程目标

这个 codelab 的目标是:

  • 学会什么:开发一个使用 MediaPipe 的 iOS 应用,并在 iOS 上运行一个 MediaPipe 图(graph)。
  • 构建什么:一个简单的相机应用,对 iOS 设备的实时视频流做 Sobel 边缘检测。

最终效果是:打开应用后,屏幕中看到的不是原始相机画面,而是经过 LuminanceCalculator + SobelEdgesCalculator 两级计算得到的边缘检测结果。

环境准备

准备工作分三步:

  1. 在系统上安装 MediaPipe,详见 安装指南
  2. 配置 iOS 开发设备(真机)。
  3. 安装 Bazel,用于构建和部署 iOS 应用(Bazel 是 Google 开源的构建工具,MediaPipe 全仓库统一用 Bazel 构建)。

一个需要提前明确的约束:MediaPipe 为 iOS 提供 Objective-C 绑定,本教程的所有 iOS 示例代码都使用 Objective-C 与 C++ 混编,即 .mm 文件(例如 MPPGraph.h 中直接用 #error 强制要求只有 Objective-C++ 文件才能包含它,因为它要暴露 mediapipe::CalculatorGraphConfig 等 C++ 类型)。

边缘检测图(Graph)解析

教程使用的图配置是 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"
}

这个图的结构是一条两节点的流水线:

流名 方向 说明
input_video 输入 由 iOS 相机提供的全部视频帧
luma_video 内部 灰度化后的中间帧
output_video 输出 Sobel 边缘检测结果,应用最终渲染它
  • LuminanceCalculator:接收单个 packet(图像帧),用 OpenGL shader 做亮度转换,结果帧写入 luma_video
  • SobelEdgesCalculator:对 luma_video 中的帧做 Sobel 边缘检测,结果写入 output_video

iOS 应用只负责显示 output_video 的输出帧。

mediapipe/graphs/edge_detection/BUILD 可以看到这条流水线在构建系统里的落点:

cc_library(
    name = "mobile_calculators",
    deps = [
        "//mediapipe/calculators/image:luminance_calculator",
        "//mediapipe/calculators/image:sobel_edges_calculator",
    ],
)

mediapipe_binary_graph(
    name = "mobile_gpu_binary_graph",
    graph = "edge_detection_mobile_gpu.pbtxt",
    output_name = "mobile_gpu.binarypb",
)

两个目标各有分工:

  • mobile_calculators:聚合图里用到的两个计算器的 C++ 实现,应用必须依赖它,否则运行时找不到 LuminanceCalculator/SobelEdgesCalculator 的注册实现。
  • mobile_gpu_binary_graph:用 mediapipe_binary_graph 规则把 edge_detection_mobile_gpu.pbtxt 序列化成 mobile_gpu.binarypb 二进制文件打进 App bundle。这正是后面 Objective-C 代码里 loadGraphFromResource: 读取的资源——资源名 mobile_gpu 加扩展名 binarypb,与 output_name 一一对应。

最小应用搭建

教程的第一步是搭一个能构建、能安装、能显示空白屏幕的最小 iOS 应用。

  1. 在 Xcode 中通过 File > New > Single View App 创建项目。
  2. Product Name 设为 HelloWorld,组织标识符例如 com.google.mediapipe(组织标识符 + 产品名即 bundle_id,如 com.google.mediapipe.HelloWorld)。
  3. 语言选 Objective-C,保存到某个位置(记为 $PROJECT_TEMPLATE_LOC)。

注意:教程中的 HelloWorld.xcodeproj 本身不会被用来构建,实际构建走 Bazel。模板目录里的文件为:AppDelegate.h/.mViewController.h/.mmain.mInfo.plistMain.storyboardLaunch.storyboardAssets.xcassets。较新版本的 Xcode 还会生成 SceneDelegate.h/.m,需要一并复制并加入下面的 BUILD 文件。

把这些文件复制到一处能访问 MediaPipe 源码的位置(例如 mediapipe/examples/ios/HelloWorld,下文记为 $APPLICATION_PATH),然后创建 BUILD 文件:

MIN_IOS_VERSION = "12.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:把 AppDelegateViewControllermain.m 和 storyboard 组织成 Objective-C 库,此时只依赖 UIKit
  • ios_application:基于上面的库构建可安装到 iOS 设备的 App。

注意:provisioning_profile 需要指向你自己的 iOS 开发者签名配置,否则无法在真机上运行。

在终端执行构建命令:

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

例如构建仓库中的 mediapipe/examples/ios/helloworld

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

然后在 Xcode 中打开 Window > Devices and Simulators,选中设备,把生成的 .ipa 添加到设备上。iOS MediaPipe 应用的安装与编译细节另见 iOS 指南。打开应用后,它应该显示一块空白白屏——这就是管线搭建完成的第一里程碑。

用相机获取实时视频

接下来接入相机。MediaPipe 提供的 MPPCameraInputSource 封装了 AVCaptureSession,负责从相机取帧。

第一步:修改 Info.plist 声明相机用途NSCameraUsageDescription),这是 iOS 允许应用访问相机的前提。

第二步:在 ViewController.m 中引入并初始化相机源

#import "mediapipe/objc/MPPCameraInputSource.h"

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

-(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;
}

对照 MPPCameraInputSource.h 的接口,这几个属性各有明确含义:sessionPreset 决定捕获分辨率档位;cameraPosition 选择前置/后置摄像头(设备有多摄时生效);orientation 修正帧缓冲相对竖屏的旋转方向。此外该头文件还暴露了 useDepth(深度数据)、autoRotateBuffersvideoMirroredcameraIntrinsicMatrix 等属性,本教程用不到但可以作为扩展入口。

第三步:让 ViewController 成为相机源的 delegateMPPCameraInputSource 继承自 MPPInputSource,后者定义了 MPPInputSourceDelegate 协议来向外分帧:

@interface ViewController () <MPPInputSourceDelegate>

相机帧的处理不能放在主队列上,需要一个专用串行队列:

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

在文件顶部、ViewController 的 interface/implementation 之前声明队列标签,并在初始化 _cameraSource 之前创建队列:

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

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);

viewDidLoad() 中初始化 _cameraSource 之后绑定 delegate 和队列:

[_cameraSource setDelegate:self queue:_videoQueue];

这里采用 QoS 为 QOS_CLASS_USER_INTERACTIVE 的串行队列,保证帧回调既串行有序,又具备与用户交互同等的高优先级。

第四步:搭好显示通道。在实现任何 delegate 方法之前,先要有地方把帧画出来。MediaPipe 的 MPPLayerRenderer 可以在一个 CAEAGLLayer 上渲染 CVPixelBufferRef——而 CVPixelBufferRef 恰好就是 MPPCameraInputSource 交给 delegate 的帧类型:

#import "mediapipe/objc/MPPLayerRenderer.h"

ViewController 的 implementation 块中添加:

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

Main.storyboard 里,从对象库拖一个 UIViewViewControllerView 中,建立到 _liveView 的 outlet 引用,并调整大小使其居中铺满整个屏幕。然后回到 ViewController.m,在 viewDidLoad() 中初始化渲染器:

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

MPPFrameScaleModeFillAndCrop 让帧充满视图并裁剪溢出部分,保证边缘检测画面始终铺满全屏。

第五步:实现分帧回调MPPInputSourceDelegate 中提供帧的方法是 processVideoFrame:timestamp:fromSource:(见 MPPInputSource.h 的协议定义):

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

先校验帧来自正确的源,再 CFRetain 延长像素缓冲的生命周期,切到主队列渲染后 CFRelease

第六步:权限请求与启动相机。在 viewWillAppear:(BOOL)animated 中请求相机权限,获准后启动:

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

MPPCameraInputSource 提供 requestCameraAccessWithCompletionHandler: 方法(头文件注释说明:应在 init 之后、start 之前调用;若用户此前已授权或拒绝,会直接返回缓存的响应):

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

第七步:补依赖并运行。在 BUILD 文件中给 objc_library 添加:

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

构建运行后,接受相机权限提示,屏幕应出现实时相机预览。至此输入侧管线打通,可以进入 MediaPipe 图的处理环节。

在 iOS 中使用 MediaPipe 图

添加相关依赖

图相关依赖分两部分,都加进 BUILD 文件:

# objc_library 的 data 字段
"//mediapipe/graphs/edge_detection:mobile_gpu_binary_graph",
# objc_library 的 deps 字段
"//mediapipe/graphs/edge_detection:mobile_calculators",

前者把 mobile_gpu.binarypb 打进 bundle,后者链接两个计算器的实现(二者作用前面已在 BUILD 解析一节说明)。最后把 ViewController.m 重命名为 ViewController.mm,以便使用 Objective-C++ 引入 MediaPipe 的 C++ 类型。

在 ViewController 中使用图

引入 MPPGraph

#import "mediapipe/objc/MPPGraph.h"

声明图名与输入/输出流名常量——注意 kGraphName 就是 bundle 里 binarypb 资源名 mobile_gpu

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;

加载图配置MPPGraph 的指定初始化器是 initWithGraphConfig:(参数为 mediapipe::CalculatorGraphConfig proto,见 MPPGraph.h),因此需要一个从 bundle 资源加载并反序列化的函数:

+ (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];
self.mediapipeGraph.delegate = self;

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

其中两个关键配置值得展开:

  • addFrameOutputStream:outputPacketType::声明从哪个输出流、以什么包类型接收结果。MPPPacketTypePixelBuffer 对应回调 mediapipeGraph:didOutputPixelBuffer:fromStream:,可直接拿到 CVPixelBufferRef 交给渲染器,无需 CPU 侧格式转换。
  • maxFramesInFlight = 2MPPGraph 头文件对它的注释是——当在途帧数超过该值时丢弃新帧,避免处理速度跟不上视频输入时压垮较慢的设备,默认 0 表示不限制。实时相机场景设为小值,是防止内存竞争的直接手段。

启动顺序。在权限回调里先启图、再启相机:

[_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];
    });
  }
}];

教程特别强调:必须先启动图并等待其完成初始运行(waitUntilIdleWithError:),再启动相机,这样图在相机开始吐帧时已就绪,不会丢帧。这个顺序在仓库现行的 CommonViewController.mm 中同样被保留为 startGraphAndCamera 方法,逻辑一字未变。

把帧送入图。修改 processVideoFrame:timestamp:fromSource:,不再直接渲染,而是把帧作为 MPPPacketTypePixelBuffer 类型的 packet 发进 input_video

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

对照 MPPGraph.h 中该方法的注释:图必须先已启动;超出 maxFramesInFlight 时丢帧并返回 NO;时间戳默认自上次调用自动递增。

接收输出并渲染。实现 MPPGraphDelegate 的输出回调,把 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);
    });
  }
}

注意回调运行在 MediaPipe 的工作线程上,因此和输入侧一样,先 retain、切主队列渲染、再 release。最后把 delegate 协议声明补全:

@interface ViewController () <MPPGraphDelegate, MPPInputSourceDelegate>

构建并在设备上运行,就能看到边缘检测图跑在实时视频流上的结果。

从仓库现状看这套代码的演进

教程末尾说明:iOS 示例现在统一采用 common 模板应用,教程中的代码正体现在模板中,而 helloworld 应用则带上了边缘检测图的 BUILD 依赖。对照仓库现状,有两点值得注意:

1. helloworld 的 BUILD 已改为复用 common 模板。当前 mediapipe/examples/ios/helloworld/BUILD 不再自带 AppDelegate/ViewController 源码,而是:

MIN_IOS_VERSION = "15.0"

ios_application(
    name = "HelloWorldApp",
    app_icons = ["//mediapipe/examples/ios/common:AppIcon"],
    bundle_id = BUNDLE_ID_PREFIX + ".HelloWorld",
    ...
    provisioning_profile = example_provisioning(),
    deps = [":HelloWorldAppLibrary"],
)

objc_library(
    name = "HelloWorldAppLibrary",
    data = ["//mediapipe/graphs/edge_detection:mobile_gpu_binary_graph"],
    deps = [
        "//mediapipe/examples/ios/common:CommonMediaPipeAppLibrary",
        "//mediapipe/graphs/edge_detection:mobile_calculators",
        "//third_party/apple_frameworks:Metal",
    ],
)

与教程版本相比有三处差异:MIN_IOS_VERSION 从 12.0 提升到 15.0;bundle_id 前缀与签名配置改由 bundle_id.bzl 统一管理(BUNDLE_ID_PREFIXexample_provisioning()),方便替换为你自己的开发者身份;应用逻辑统一收敛到 common/BUILDCommonMediaPipeAppLibrary(其依赖项正是教程第七步那三个 //mediapipe/objc:... 目标加 AVFoundation/CoreGraphics/CoreMedia 框架)。教程的图依赖思路(mobile_gpu_binary_graphdatamobile_calculatorsdeps)则原样保留。

2. 模板在教程代码之上补了生命周期管理与时间戳转换CommonViewController.mm 完整实现了教程的全部模式——串行 QOS_CLASS_USER_INTERACTIVE 视频队列、loadGraphFromResource:binarypb、先图后相机的 startGraphAndCamera——并额外增加了:

  • dealloc 中的清理序列:置空 delegate、cancelcloseAllInputStreamsWithError:waitUntilDoneWithError:。这与 MPPGraph.h 的要求一致:停止图之前必须关闭所有输入流,且 waitUntilDoneWithError: 不会超时,不应在主线程调用。
  • MPPTimestampConverter 把相机帧的 CMTime 转成 mediapipe::Timestamp 后调用带 timestamp: 参数的 sendPixelBuffer:,而不是依赖时间戳自动递增——对实时流来说更严谨。
  • 前置摄像头时自动 videoMirrored = YES,并支持从 Info.plistGraphName/GraphInputStream/GraphOutputStream 键配置要跑的图,这也是 helloworld 等纯 BUILD + Info.plist 目录能各自跑不同图的原因。

小结与要点回顾

  • 整条 iOS 实时管线的骨架是:MPPCameraInputSource(取帧)→ 专用串行视频队列 → MPPGraph.sendPixelBuffer:intoStream:packetType:(入图)→ MPPGraphDelegate 回调(出图)→ MPPLayerRenderer(渲染)
  • 图配置在构建期由 mediapipe_binary_graph 序列化为 bundle 内的 mobile_gpu.binarypb,运行期经 CalculatorGraphConfig 反序列化后交给 MPPGraphmobile_calculators 提供计算器的 C++ 实现,两者缺一不可。
  • 实时处理的两个关键参数:先启图再启相机的启动顺序,以及 maxFramesInFlight = 2 的丢帧背压,都是防止处理跟不上相机帧率时内存积压的手段。
  • 动手时建议直接以 mediapipe/examples/ios/helloworldmediapipe/examples/ios/common 为参照实现,教程中的逐步代码与它们是同一套模式的先后两种形态。
登录后查看全文
热门项目推荐
相关项目推荐