MediaPipe iOS 实时视频管线实战:从零构建相机边缘检测应用
本文基于仓库中的 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 两级计算得到的边缘检测结果。
环境准备
准备工作分三步:
- 在系统上安装 MediaPipe,详见 安装指南。
- 配置 iOS 开发设备(真机)。
- 安装 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 应用。
- 在 Xcode 中通过 File > New > Single View App 创建项目。
- Product Name 设为
HelloWorld,组织标识符例如com.google.mediapipe(组织标识符 + 产品名即bundle_id,如com.google.mediapipe.HelloWorld)。 - 语言选 Objective-C,保存到某个位置(记为
$PROJECT_TEMPLATE_LOC)。
注意:教程中的 HelloWorld.xcodeproj 本身不会被用来构建,实际构建走 Bazel。模板目录里的文件为:AppDelegate.h/.m、ViewController.h/.m、main.m、Info.plist、Main.storyboard 与 Launch.storyboard、Assets.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:把AppDelegate、ViewController、main.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(深度数据)、autoRotateBuffers、videoMirrored 与 cameraIntrinsicMatrix 等属性,本教程用不到但可以作为扩展入口。
第三步:让 ViewController 成为相机源的 delegate。MPPCameraInputSource 继承自 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 里,从对象库拖一个 UIView 到 ViewController 的 View 中,建立到 _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 = 2:MPPGraph头文件对它的注释是——当在途帧数超过该值时丢弃新帧,避免处理速度跟不上视频输入时压垮较慢的设备,默认 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_PREFIX、example_provisioning()),方便替换为你自己的开发者身份;应用逻辑统一收敛到 common/BUILD 的 CommonMediaPipeAppLibrary(其依赖项正是教程第七步那三个 //mediapipe/objc:... 目标加 AVFoundation/CoreGraphics/CoreMedia 框架)。教程的图依赖思路(mobile_gpu_binary_graph 进 data、mobile_calculators 进 deps)则原样保留。
2. 模板在教程代码之上补了生命周期管理与时间戳转换。CommonViewController.mm 完整实现了教程的全部模式——串行 QOS_CLASS_USER_INTERACTIVE 视频队列、loadGraphFromResource: 读 binarypb、先图后相机的 startGraphAndCamera——并额外增加了:
dealloc中的清理序列:置空 delegate、cancel、closeAllInputStreamsWithError:、waitUntilDoneWithError:。这与 MPPGraph.h 的要求一致:停止图之前必须关闭所有输入流,且waitUntilDoneWithError:不会超时,不应在主线程调用。- 用 MPPTimestampConverter 把相机帧的
CMTime转成mediapipe::Timestamp后调用带timestamp:参数的sendPixelBuffer:,而不是依赖时间戳自动递增——对实时流来说更严谨。 - 前置摄像头时自动
videoMirrored = YES,并支持从Info.plist的GraphName/GraphInputStream/GraphOutputStream键配置要跑的图,这也是 helloworld 等纯BUILD + Info.plist目录能各自跑不同图的原因。
小结与要点回顾
- 整条 iOS 实时管线的骨架是:
MPPCameraInputSource(取帧)→ 专用串行视频队列 →MPPGraph.sendPixelBuffer:intoStream:packetType:(入图)→MPPGraphDelegate回调(出图)→MPPLayerRenderer(渲染)。 - 图配置在构建期由
mediapipe_binary_graph序列化为 bundle 内的mobile_gpu.binarypb,运行期经CalculatorGraphConfig反序列化后交给MPPGraph;mobile_calculators提供计算器的 C++ 实现,两者缺一不可。 - 实时处理的两个关键参数:先启图再启相机的启动顺序,以及
maxFramesInFlight = 2的丢帧背压,都是防止处理跟不上相机帧率时内存积压的手段。 - 动手时建议直接以 mediapipe/examples/ios/helloworld 和 mediapipe/examples/ios/common 为参照实现,教程中的逐步代码与它们是同一套模式的先后两种形态。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00