OpenCV 4.13.0
开源计算机视觉库 (Open Source Computer Vision)
正在加载...
正在搜索...
未找到匹配项
G-API 人脸分析流水线

下一个教程: 将各向异性图像分割移植到 G-API

概述

在本教程中你将学习

  • 如何在 G-API 图中集成深度学习推理;
  • 如何在一个视频流上运行 G-API 图并从中获取数据。

先决条件

本示例需要

  • 配备 GNU/Linux 或 Microsoft Windows 的 PC(支持 Apple macOS,但未经测试);
  • OpenCV 4.2 或更高版本,并使用 Intel® OpenVINO™ 工具包的 Intel® 发行版构建(使用 Intel® TBB 构建是加分项);
  • 来自 OpenVINO™ 工具包 Open Model Zoo的以下拓扑
    • face-detection-adas-0001;
    • age-gender-recognition-retail-0013;
    • emotions-recognition-retail-0003.

简介:为什么要使用 G-API

许多计算机视觉算法在视频流而非单个图像上运行。流处理通常包含多个步骤——例如解码、预处理、检测、跟踪、分类(对检测到的对象)和可视化——构成一个视频处理流水线。此外,此类流水线的许多步骤可以并行运行——现代平台在同一芯片上具有不同的硬件块,如解码器和 GPU,并且可以插入额外的加速器作为扩展,例如用于深度学习卸载的 Intel® Movidius™ Neural Compute Stick。

鉴于所有这些选择的多样性和视频分析算法的多样性,有效管理此类流水线很快就成了一个问题。当然,这可以手动完成,但这种方法无法扩展:如果算法需要更改(例如,添加了新的流水线步骤),或者如果它被移植到具有不同功能的​​新平台,则需要对整个流水线进行重新优化。

从 4.2 版本开始,OpenCV 提供了一个解决方案。OpenCV G-API 现在可以在一个单独的流水线中管理深度学习推理(任何现代分析流水线的基石)、传统的计算机视觉以及视频捕获/解码。G-API 本身负责流水线——因此,如果算法或平台发生变化,执行模型会自动适应。

流水线概述

我们的示例应用程序基于 OpenVINO™ 工具包 Open Model Zoo 中的“交互式人脸检测”演示。简化的流水线包含以下步骤:

  1. 图像采集和解码;
  2. 带预处理的检测;
  3. 对每个检测到的对象使用两个网络进行分类(带预处理);
  4. 可视化。

构建流水线

为视频流场景构建 G-API 图与 G-API 的常规用法差别不大——它仍然是关于定义图的数据(使用cv::GMatcv::GScalarcv::GArray)和对其执行的操作。推理也成为图中的一个操作,但定义方式略有不同。

声明深度学习拓扑

与传统 CV 函数(请参阅核心图像处理)不同,G-API 为每个函数声明不同的操作,而在 G-API 中,推理是一个通用的单一操作 cv::gapi::infer<>。和往常一样,这只是一个接口,它可以在底层以多种方式实现。在 OpenCV 4.2 中,只有基于 OpenVINO™ 推理引擎的后端可用,OpenCV 自有的 DNN 模块后端即将推出。

cv::gapi::infer<> 由我们将要执行的拓扑的详细信息参数化。与操作一样,G-API 中的拓扑是强类型的,并且使用特殊宏 G_API_NET() 定义

// 人脸检测器:接受一个 Mat,返回另一个 Mat
G_API_NET(Faces, <cv::GMat(cv::GMat)>, "face-detector");
// 年龄/性别识别 - 接受一个 Mat,返回两个
// 一个用于年龄,一个用于性别。在 G-API 中,多返回值操作
// 使用 std::tuple<> 定义。
using AGInfo = std::tuple<cv::GMat, cv::GMat>;
G_API_NET(AgeGender, <AGInfo(cv::GMat)>, "age-gender-recoginition");
// 情感识别 - 接受一个 Mat,返回另一个。
G_API_NET(Emotions, <cv::GMat(cv::GMat)>, "emotions-recognition");

与使用 G_API_OP() 定义操作类似,网络描述需要三个参数:

  1. 一个类型名称。每个定义的拓扑都声明为一个独立的 C++ 类型,该类型在程序中进一步使用——如下所示;
  2. 一个 std::function<> 风格的 API 签名。G-API 将网络视为常规的“函数”,这些函数接收和返回数据。这里,网络 Faces(一个检测器)接收一个 cv::GMat 并返回一个 cv::GMat,而网络 AgeGender 已知提供两个输出(分别为年龄和性别 blob)——因此,它具有 std::tuple<> 作为返回类型。
  3. 拓扑名称——可以是任何非空字符串,G-API 使用这些名称在内部区分网络。名称在单个图的作用域内应唯一。

构建 GComputation

现在,上述流水线在 G-API 中表示如下:

cv::GComputation pp([]() {
// 声明一个空的 GMat - 流水线的起点。
// 在输入帧上运行人脸检测。结果是一个单独的 GMat,
// 内部表示 1x1x200x7 的 SSD 输出。
// 这是 infer 的单块版本
// - 推理在整个输入图像上运行;
// - 图像将自动转换为并调整为网络期望的格式
// 。
// 使用自定义内核解析 SSD 输出为 ROI 列表(矩形)。
// 注意:解析 SSD 可能会成为一个“标准”内核。
cv::GArray<cv::Rect> faces = custom::PostProc::on(detections, in);
// 现在对每个检测到的人脸运行年龄/性别模型。此模型有两个
// 输出(分别为年龄和性别)。
// 这里使用了特殊面向 ROI 列表的 infer<>() 形式
// - 第一个输入参数是要处理的矩形列表,
// - 第二个参数是要从中获取 ROI 的图像;
// - 裁剪/调整大小/布局转换对每个图像块自动进行
// 来自列表
// - 推理结果也以列表形式返回(GArray<>)
// - 由于有两个输出,infer<> 返回两个数组(通过 std::tuple)。
std::tie(ages, genders) = cv::gapi::infer<custom::AgeGender>(faces, in);
// 对每个人脸识别情感。
// 这里也使用了面向 ROI 列表的 infer<>()。
// 由于 custom::Emotions 网络只产生一个输出,因此这里只返回一个
// GArray<>。
// 也将解码后的帧作为结果返回。
// 输入矩阵不能指定为输出矩阵,因此这里使用 copy()
// (此 copy 将在未来被优化掉)。
cv::GMat frame = cv::gapi::copy(in);
// 现在指定计算的边界 - 我们的流水线消耗
// 一张图像并产生五个输出。
cv::GOut(frame, faces, ages, genders, emotions));
});

每个流水线都以声明空数据对象开始——这些对象充当流水线的输入。然后,我们调用专门针对 Faces 检测网络的通用 cv::gapi::infer<>cv::gapi::infer<> 从其模板参数继承签名——在这种情况下,它期望一个输入 cv::GMat 并产生一个输出 cv::GMat

在此示例中,我们使用预训练的基于 SSD 的网络,其输出需要解析为一组检测(对象感兴趣区域,ROI)。这由自定义操作 custom::PostProc 完成,该操作返回一个矩形数组(类型为 cv::GArray<cv::Rect>)给流水线。此操作还会根据置信度阈值过滤结果——这些细节隐藏在内核本身中。尽管如此,在图构建时,我们只操作接口,而无需实际内核来表达流水线——因此,此后处理的实现将在后面列出。

在检测结果输出被解析为对象数组后,我们可以对其中任何一个进行分类。G-API 尚不支持图内循环(如 for_each())的语法,但 cv::gapi::infer<> 提供了一种特殊的面向列表的重载。

用户可以调用 cv::gapi::infer<>,并将 cv::GArray 作为第一个参数,因此 G-API 会假定它需要在给定帧(第二个参数)的每个矩形上运行关联的网络。此类操作的结果也是一个列表——一个 cv::GArraycv::GMat

由于 AgeGender 网络本身会产生两个输出,因此其面向列表的 cv::gapi::infer 的输出类型是数组的元组。我们使用 std::tie() 将其分解为两个独立的对象。

Emotions 网络产生一个输出,因此其面向列表的推理返回类型为 cv::GArray<cv::GMat>

配置流水线

G-API 严格区分构建和配置——其理念是保持算法代码本身与平台无关。在上面的列表显示中,我们只声明了我们的操作并表达了整体数据流,但甚至没有提到我们使用了 OpenVINO™。我们只描述了我们做什么,而不是我们怎么做。将这两个方面明确分开是 G-API 的设计目标。

平台特定的细节出现在流水线编译时——即从声明性形式转变为可执行形式。如何运行事物的指定方式通过编译参数来完成,新的推理/流功能也不例外。

G-API 构建在实现接口的后端之上(有关详细信息,请参阅架构内核)——因此 cv::gapi::infer<> 是一个可以由不同后端实现的函数。在 OpenCV 4.2 中,只有用于推理的 OpenVINO™ 推理引擎后端可用。G-API 中的每个推理后端都必须提供一个特殊的参数化结构来表达后端特定的神经网络参数——在这种情况下,它是 cv::gapi::ie::Params

cmd.get<std::string>("fdm"), // 读取命令行参数:拓扑 IR 路径
cmd.get<std::string>("fdw"), // 读取命令行参数:权重路径
cmd.get<std::string>("fdd"), // 读取命令行参数:设备指定符
};
cmd.get<std::string>("agem"), // 读取命令行参数:拓扑 IR 路径
cmd.get<std::string>("agew"), // 读取命令行参数:权重路径
cmd.get<std::string>("aged"), // 读取命令行参数:设备指定符
}.cfgOutputLayers({ "age_conv3", "prob" });
cmd.get<std::string>("emom"), // 读取命令行参数:拓扑 IR 路径
cmd.get<std::string>("emow"), // 读取命令行参数:权重路径
cmd.get<std::string>("emod"), // 读取命令行参数:设备指定符
};

此处,我们定义了三个参数对象:det_netage_netemo_net。每个对象都是一个 cv::gapi::ie::Params 结构,用于为我们使用的每个特定网络进行参数化。在编译阶段,G-API 使用此信息自动匹配参数与图中的 cv::gapi::infer<> 调用。

无论拓扑如何,每个参数结构都用三个字符串参数构造——特定于 OpenVINO™ 推理引擎:

  1. 拓扑的中间表示(.xml 文件)的路径;
  2. 拓扑模型权重的路径(.bin 文件);
  3. 运行设备——“CPU”、“GPU”等——取决于您的 OpenVINO™ 工具包安装。这些参数取自命令行解析器。

一旦网络定义完毕且自定义内核实现完成,流水线就会为流式处理进行编译:

// 形成一个内核包(包含我们后处理的一个 OpenCV 实现)
// 和一个网络包(包含我们的三个网络)。
auto networks = cv::gapi::networks(det_net, age_net, emo_net);
// 编译我们的流水线,并将我们的内核和网络作为
// 参数传递。这是 G-API 学习我们实际操作的网络
// 和内核的地方(图描述本身对此一无所知)。
auto cc = pp.compileStreaming(cv::compile_args(kernels, networks));
cv::GComputation::compileStreaming() 触发了一种特殊的面向视频的图编译形式,G-API 在其中尝试优化吞吐量。此编译的结果是一个特殊类型 cv::GStreamingCompiled 的对象——与传统的、可调用的 cv::GCompiled 对象相比,这些对象在语义上更接近媒体播放器。

cv::GComputation::compileStreaming() 中无需传递描述输入视频流格式的元数据参数——G-API 会自动确定输入向量的格式,并实时调整流水线以适应这些格式。用户仍然可以像使用常规 cv::GComputation::compile() 一样在那里传递元数据,以将流水线固定到特定的输入格式。

注意
运行流水线

流水线优化基于同时处理多个输入视频帧,并行运行流水线的不同步骤。这就是为什么当框架完全控制视频流时它效果最好。

流 API 背后的理念是,用户指定流水线的输入源,然后 G-API 会自动管理其执行,直到源结束或用户中断执行。G-API 从源拉取新的图像数据并将其传递给流水线进行处理。

流源由接口 cv::gapi::wip::IStreamSource 表示。实现此接口的对象可以通过 cv::gin() 辅助函数作为常规输入传递给 GStreamingCompiled。在 OpenCV 4.2 中,每个流水线只允许一个流源——这个要求将在未来放宽。

OpenCV 提供了出色的 cv::VideoCapture 类,并且默认情况下,G-API 提供了一个基于它的流源类——cv::gapi::wip::GCaptureSource。用户可以实现自己的流源,例如使用 VAAPI 或其他媒体或网络 API。

示例应用程序如下指定输入源:

auto in_src = cv::gapi::wip::make_src<cv::gapi::wip::GCaptureSource>(input);

cc.setSource(cv::gin(in_src));
请注意,GComputation 仍然可以有多个输入,如 cv::GMatcv::GScalarcv::GArray 对象。用户也可以在输入向量中传递其相应的宿主端类型(cv::Matcv::Scalar、std::vector<>),但在流模式下,这些对象将创建“无限”的常量流。允许混合真实的视频源流和常量数据流。

运行流水线很容易——只需调用 cv::GStreamingCompiled::start() 并使用阻塞的 cv::GStreamingCompiled::pull() 或非阻塞的 cv::GStreamingCompiled::try_pull() 获取数据;重复直到流结束。

// 指定数据源后,启动执行

cc.start();
// 声明我们将从流水线接收的数据对象。
cv::Mat frame; // 捕获的帧本身
std::vector<cv::Rect> faces; // 检测到的人脸数组
std::vector<cv::Mat> out_ages; // 推断年龄数组(每张脸一个 blob)
std::vector<cv::Mat> out_genders; // 推断性别数组(每张脸一个 blob)
std::vector<cv::Mat> out_emotions; // 分类情感数组(每张脸一个 blob)
// 实现不同的执行策略,具体取决于显示选项
// 以获得最佳性能。
while (cc.running()) {
auto out_vector = cv::gout(frame, faces, out_ages, out_genders, out_emotions);
if (no_show) {
// 这纯粹是视频处理。无需
// 与 UI 渲染进行平衡。
// 使用阻塞 pull() 获取
// 数据。如果流结束,则中断循环。
if (!cc.pull(std::move(out_vector)))
break;
} else if (!cc.try_pull(std::move(out_vector))) {
// 使用非阻塞 try_pull() 获取数据。
// 如果没有数据,让 UI 刷新(并处理按键)
if (cv::waitKey(1) >= 0) break;
else continue;
}
// 在这一点上,我们肯定有数据(通过
// 阻塞或非阻塞方式获取)。
frames++;
labels::DrawResults(frame, faces, out_ages, out_genders, out_emotions);
labels::DrawFPS(frame, frames, avg.fps(frames));
if (!no_show) cv::imshow("Out", frame);
}

上面的代码可能看起来很复杂,但实际上它处理了两种模式——有和没有图形用户界面(GUI)。

  • 当示例在“无头”模式下运行时(设置了 `--pure` 选项),此代码仅使用阻塞的 pull() 从流水线中拉取数据,直到流水线结束。这是最高效的执行模式。
  • 当结果也显示在屏幕上时,窗口系统需要一些时间来刷新窗口内容并处理 GUI 事件。在这种情况下,演示使用非阻塞的 try_pull() 拉取数据,直到没有更多数据可用(但它不标记流结束——只是表示新数据尚未准备好),然后才显示最新获取的结果并刷新屏幕。通过这种技巧减少 GUI 占用的时间可以略微提高整体性能。

与串行模式的比较

示例还可以以串行模式运行,用于参考和基准测试。在这种情况下,使用常规的 cv::GComputation::compile(),并生成常规的单帧 cv::GCompiled 对象;G-API 内部不应用流水线优化;用户负责从 cv::VideoCapture 对象获取图像帧并将其传递给 G-API。

cv::VideoCapture cap(input);
cv::Mat in_frame, frame; // 捕获的帧本身
std::vector<cv::Mat> out_ages; // 推断年龄数组(每张脸一个 blob)
std::vector<cv::Mat> out_genders; // 推断性别数组(每张脸一个 blob)
std::vector<cv::Mat> out_emotions; // 分类情感数组(每张脸一个 blob)
// 实现不同的执行策略,具体取决于显示选项
while (cap.read(in_frame)) {
pp.apply(cv::gin(in_frame),
cv::gout(frame, faces, out_ages, out_genders, out_emotions),
cv::compile_args(kernels, networks));
labels::DrawResults(frame, faces, out_ages, out_genders, out_emotions);
frames++;
if (frames == 1u) {
// 编译在此处即时发生,因此在处理完第一帧后开始计时
//
avg.start();
} else {
// 测量并绘制所有其他帧的 FPS
labels::DrawFPS(frame, frames, avg.fps(frames-1));
}
if (!no_show) {
cv::imshow("Out", frame);
if (cv::waitKey(1) >= 0) break;
}
}

在测试机器(Intel® Core™ i5-6600)上,使用支持 [Intel® TBB] 构建的 OpenCV,将检测器网络分配给 CPU,将分类器分配给 iGPU,流水线化示例的性能优于串行模式 1.36 倍(因此整体吞吐量提高了 36%)。

结论

G-API 引入了一种构建和优化混合流水线的技术方法。切换到新的执行模型不需要更改使用 G-API 表达的算法代码——只需要更改触发图的方式。

列表:后处理内核

G-API 提供了一种简便的方法,可以将自定义代码插入流水线,即使它处于流式处理模式并处理张量数据。推理结果由多维 cv::Mat 对象表示,因此访问它们就像访问常规 DNN 模块一样简单。

基于 OpenCV 的 SSD 后处理内核在此示例中定义和实现如下:

// SSD 后处理函数——这不是一个网络,而是一个内核。
// 内核体单独声明,这只是一个接口。
// 此操作接受两个 Mats(检测结果和源图像),
// 并返回一个 ROI 向量(按默认阈值过滤)。
// 阈值(或要选择的类别)可以成为参数,但由于
// 此内核是自定义的,所以意义不大。
G_API_OP(PostProc, <cv::GArray<cv::Rect>(cv::GMat, cv::GMat)>, "custom.fd_postproc") {
static cv::GArrayDesc outMeta(const cv::GMatDesc &, const cv::GMatDesc &) {
// G-API 引擎需要此函数来弄清楚
// 在给定输入参数的情况下,输出格式是什么。
// 由于输出是一个数组(具有特定类型),
// 因此无需描述。
}
};
// 上述内核的 OpenCV 实现。
GAPI_OCV_KERNEL(OCVPostProc, PostProc) {
static void run(const cv::Mat &in_ssd_result,
const cv::Mat &in_frame,
std::vector<cv::Rect> &out_faces) {
const int MAX_PROPOSALS = 200;
const int OBJECT_SIZE = 7;
const cv::Size upscale = in_frame.size();
const cv::Rect surface({0,0}, upscale);
out_faces.clear();
const float *data = in_ssd_result.ptr<float>();
for (int i = 0; i < MAX_PROPOSALS; i++) {
const float image_id = data[i * OBJECT_SIZE + 0]; // batch id
const float confidence = data[i * OBJECT_SIZE + 2];
const float rc_left = data[i * OBJECT_SIZE + 3];
const float rc_top = data[i * OBJECT_SIZE + 4];
const float rc_right = data[i * OBJECT_SIZE + 5];
const float rc_bottom = data[i * OBJECT_SIZE + 6];
if (image_id < 0.f) { // 表示检测结束
break;
}
if (confidence < 0.5f) { // 硬编码的快照
continue;
}
// 将浮点坐标转换为绝对图像
// 帧坐标;裁剪到源图像边界。
rc.x = static_cast<int>(rc_left * upscale.width);
rc.y = static_cast<int>(rc_top * upscale.height);
rc.width = static_cast<int>(rc_right * upscale.width) - rc.x;
rc.height = static_cast<int>(rc_bottom * upscale.height) - rc.y;
out_faces.push_back(rc & surface);
}
}
};