图形API——使用 G-API 的人脸分析管道 OpenCV v4.8.0

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

概述

在本教程中,您将学习

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

前提条件

本示例需要

  • 使用 GNU/Linux 或 Microsoft Windows 的 PC(支持 Apple macOS,但未进行测试);
  • 使用Intel ® OpenVINO™ 工具包分发版构建的 OpenCV 4.2 或更高版本(使用Intel ® TBB 构建更佳);
  • OpenVINO™ 工具包开放模型 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™ 工具包开放模型动物园中的 "交互式人脸检测 "演示。简化的流程包括以下步骤:

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

原图
visio自绘图

构建流水线

为视频流构建 G-API 图形与常规使用 G-API 并无太大区别–仍然是定义图形数据(使用 cv::GMatcv::GScalarcv::GArray)和对其进行操作。推理也成为图中的一种操作,但定义方式略有不同。

声明深度学习拓扑

与传统的 CV 函数(参见 coreimgproc)不同,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,返回两个 Mat:
// 一个代表年龄,一个代表性别。在 G-API 中,多返回值操作
// 使用 std::tuple<> 进行定义。
using AGInfo = std::tuple<cv::GMat, cv::GMat>;
G_API_NET(AgeGender, <AGInfo(cv::GMat)>,   "age-gender-recoginition");
// 情绪识别 - 接收一个 Mat,返回另一个 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 - 管道的起点。
            cv::GMat in;
            // 在输入帧上运行人脸检测。结果是一个单一的 GMat、
            // 内部代表 1x1x200x7 SSD 输出。
            // 这是推理的单补丁版本:
            // - 在整个输入图像上运行推理;
            // 自动将图像转换并调整为网络所需的格式。
            cv::GMat detections = cv::gapi::infer<custom::Faces>(in);
            // 使用自定义内核将 SSD 输出解析为 ROI(矩形)列表。
            // 自定义内核。注意:解析 SSD 可能会成为 "标准 "内核。
            cv::GArray<cv::Rect> faces = custom::PostProc::on(detections, in);
            // 现在在每个检测到的面孔上运行年龄/性别模型。该模型有两个
            // 输出(分别为年龄和性别)。
            // 这里使用的是面向 ROI 列表的特殊形式的 infer<>():
            // - 第一个输入参数是要处理的矩形列表、
            // - 第二个参数是要提取 ROI 的图像;
            // - 裁剪/调整大小/布局转换自动发生在每个图像补丁上。
            // 自动进行裁剪/尺寸/布局转换
            // - 推断结果也以列表(GArray<>)形式返回
            // - 由于有两个输出,infer<> 返回两个数组(通过 std::tuple)。
            cv::GArray<cv::GMat> ages;
            cv::GArray<cv::GMat> genders;
            std::tie(ages, genders) = cv::gapi::infer<custom::AgeGender>(faces, in);
            // 识别每张脸的情绪。
            // 这里也使用面向 ROI 列表的 infer<>()。
            // 由于 custom::Emotions 网络只产生一个输出,因此只返回一个
            // GArray<> 返回。
            cv::GArray<cv::GMat> emotions = cv::gapi::infer<custom::Emotions>(faces, in);
            // 将解码后的帧也作为结果返回。
            // 输入矩阵不能指定为输出矩阵,因此在这里使用 copy()
            // 拷贝()(将来会对拷贝进行优化)。
            cv::GMat frame = cv::gapi::copy(in);
            // 现在指定计算的边界--我们的管道消耗
            // 一幅图像并产生五次输出。
            return cv::GComputation(cv::GIn(in),
                                    cv::GOut(frame, faces, ages, genders, emotions));
        });

每个流水线都从声明空数据对象开始,这些对象是流水线的输入。然后,我们调用专门用于 人脸检测网络的通用 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::GArray 作为第一个参数调用 cv::gapi::infer<>,这样 G-API 就会认为它需要在给定帧(第二个参数)的给定列表中的每个矩形上运行相关网络。这种操作的结果也是一个列表–cv::GMatcv::GArray

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

情绪网络会产生一个单一输出,因此其基于列表推理的返回类型是 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

    auto det_net = cv::gapi::ie::Params<custom::Faces> {
        cmd.get<std::string>("fdm"),   // 读取 cmd args:拓扑 IR 的路径
        cmd.get<std::string>("fdw"),   // 读取 cmd args:权重路径
        cmd.get<std::string>("fdd"),   // 读取 cmd args:设备说明符
    };
    auto age_net = cv::gapi::ie::Params<custom::AgeGender> {
        cmd.get<std::string>("agem"),   // 读取 cmd args:拓扑 IR 的路径
        cmd.get<std::string>("agew"),   // 读取 cmd args:权重路径
        cmd.get<std::string>("aged"),   // 读取 cmd args:设备说明符
    }.cfgOutputLayers({ "age_conv3", "prob" });
    auto emo_net = cv::gapi::ie::Params<custom::Emotions> {
        cmd.get<std::string>("emom"),   // 读取 cmd args:拓扑 IR 的路径
        cmd.get<std::string>("emow"),   // 读取 cmd args:权重路径
        cmd.get<std::string>("emod"),   // 读取 cmd args:设备说明符
    };

这里我们定义了三个参数对象:det_netage_netemo_net。每个对象都是我们使用的每个特定网络的 cv::gapi::ie::Params 结构参数化。在编译阶段,G-API 会利用这些信息自动匹配网络参数和图中的 cv::gapi::infer<> 调用。

无论拓扑结构如何,每个参数结构都由三个字符串参数构成–这是 OpenVINO™ 推理引擎所特有的:

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

一旦定义了网络并实现了自定义内核,就会编译管道:

    // 形成一个内核包(内含一个基于 OpenCV 的我们的
    // 后处理)和一个网络包(包含我们的三个网络)。
    auto kernels = cv::gapi::kernels<custom::OCVPostProc>();
    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) {
                // 这纯粹是一个视频处理过程。无需平衡
                // 与用户界面渲染之间的平衡。 使用阻塞式 pull() 获取
                // 数据。如果数据流结束,则中断循环。
                if (!cc.pull(std::move(out_vector)))
                    break;
            } else if (!cc.try_pull(std::move(out_vector))) {
                // 使用非阻塞 try_pull() 获取数据。
                // 如果没有数据,让用户界面刷新(并处理按键操作)
                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() 拉取数据,直到没有更多可用数据(但不会标记数据流的结束–只表示新数据尚未准备就绪),然后才显示最新获得的结果并刷新屏幕。用这种方法减少在图形用户界面上花费的时间,可以稍微提高整体性能。

与串行模式比较

该示例也可以串行模式运行,以作为参考和基准。在这种情况下,将使用常规的 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® TBB] 的 OpenCV 测试机(英特尔® 酷睿™ i5-6600)上,检测器网络分配给 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 引擎需要使用此函数来确定
        // 输出格式是什么。
        // 因为输出是一个数组(具有特定类型)、
        // 没有什么需要描述的。
        return cv::empty_array_desc();
    }
};
// 基于 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 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;
            }
            // 将浮点坐标转换为绝对图像
            // 帧坐标;根据源图像边界剪辑。
            cv::Rect rc;
            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);
        }
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值