OpenCV 4.12.0
开源计算机视觉
加载中...
搜索中...
无匹配项
使用 G-API 的人脸分析管道

下一教程: 在 G-API 上移植各向异性图像分割

概述

在本教程中,您将学习

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

先决条件

此示例需要

  • 装有 GNU/Linux 或 Microsoft Windows 的电脑(支持 Apple macOS 但未经测试);
  • 4.2 或更高版本的 OpenCV,使用 Intel® Distribution of OpenVINO™ Toolkit 构建(如果使用 Intel® TBB 构建则更佳);
  • OpenVINO™ Toolkit Open Model Zoo 中的以下拓扑:
    • face-detection-adas-0001;
    • age-gender-recognition-retail-0013;
    • emotions-recognition-retail-0003.

简介:为什么选择 G-API

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

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

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

管线概述

我们的示例应用程序基于 OpenVINO™ Toolkit 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™ Inference Engine 的后端可用,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()
// (此复制将在未来被优化掉)。
cv::GMat frame = cv::gapi::copy(in);
// 现在指定计算的边界 - 我们的管线消耗
// 一张图像并产生五个输出。
cv::GOut(frame, faces, ages, genders, emotions));
});

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

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

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

用户可以使用 cv::GArray 作为第一个参数调用 cv::gapi::infer<>,G-API 就会认为需要在给定帧(第二个参数)的给定列表中对每个矩形运行关联的网络。此类操作的结果也是一个列表——一个 cv::GMatcv::GArray

由于 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™ Inference Engine 后端可用于推理。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™ Inference Engine:

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

定义网络并实现自定义内核后,管线将为流式传输进行编译:

// 形成一个内核包(包含我们基于 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。

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

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) 和不带图形用户界面 (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::Rect> faces; // 检测到的人脸数组
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),OpenCV 构建时支持 [Intel® TBB],检测器网络分配给 CPU,分类器分配给 iGPU,管线化的示例性能比串行模式高出 1.36 倍(因此整体吞吐量增加了 36%)。

结论

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

代码清单:后处理内核

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

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

// SSD 后处理函数 - 这不是一个网络而是一个内核。
// 内核主体单独声明,这只是一个接口。
// 此操作接收两个 Mat(检测结果和源图像),
// 并返回一个 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]; // 批次 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);
}
}
};