上一教程: 在 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;
facial-landmarks-35-adas-0002.
人脸美化算法
我们将通过结合现代深度学习技术和传统计算机视觉来实现一个简单的人脸美化算法。该算法背后的基本思想是使面部皮肤更光滑,同时保留眼睛或嘴巴等面部特征的对比度。该算法使用 DNN 推理识别面部部件,对找到的部件应用不同的滤波器,然后使用基本的图像算术将其组合成最终结果。
简而言之,该算法描述如下
- 输入图像 \(I\) 被传递给锐化蒙版和双边滤波器(分别为 \(U\) 和 \(L\));
- 输入图像 \(I\) 被传递给基于 SSD 的人脸检测器;
- 解析 SSD 结果(一个 \([1 \times 1 \times 200 \times 7]\) 的 blob)并转换为面部数组;
- 每个面部都被传递给特征点检测器;
- 根据找到的每个面部的特征点,生成三个图像蒙版
- 背景蒙版 \(b\) – 指示要保留的原始图像区域;
- 面部部件蒙版 \(p\) – 识别要保留(锐化)的区域。
- 面部皮肤蒙版 \(s\) – 识别要模糊的区域;
- 最终结果 \(O\) 是以上特征的组合,计算方式为 \(O = b*I + p*U + s*L\)。
基于有限的特征集(每个面部只有 35 个,包括所有部分)生成面部元素蒙版并非易事,将在下面的部分中进行描述。
构建 G-API 管道
声明深度学习拓扑
此示例使用两个 DNN 检测器。每个网络接受一个输入并产生一个输出。在 G-API 中,网络使用宏 G_API_NET() 定义
有关更多信息,请参阅“人脸分析管道”教程中描述的 声明深度学习拓扑。
描述处理图
下面的代码生成上述算法的图形
{
GArrayROI garRects = custom::GFacePostProc::on(faceOut, gimgIn, config::kConfThresh);
std::tie(garElems, garJaws) = custom::GLandmPostProc::on(landmOut, garRects);
std::tie(garElsConts, garFaceConts) = custom::GGetContours::on(garElems, garJaws);
cv::GMat mskSharp = custom::GFillPolyGContours::on(gimgIn, garElsConts);
config::kGSigma);
cv::GMat mskBlur = custom::GFillPolyGContours::on(gimgIn, garFaceConts);
config::kGSigma);
cv::GMat mskFacesGaussed = mskBlurFinal + mskSharpG;
cv::GMat gimgBilat = custom::GBilatFilter::on(gimgIn, config::kBSize,
config::kBSigmaCol, config::kBSigmaSp);
cv::GMat gimgSharp = custom::unsharpMask(gimgIn, config::kUnshSigma,
config::kUnshStrength);
cv::GMat gimgBilatMasked = custom::mask3C(gimgBilat, mskBlurFinal);
cv::GMat gimgSharpMasked = custom::mask3C(gimgSharp, mskSharpG);
cv::GMat gimgInMasked = custom::mask3C(gimgIn, mskNoFaces);
cv::GMat gimgBeautif = gimgBilatMasked + gimgSharpMasked + gimgInMasked;
garFaceConts,
garElsConts,
garRects));
});
生成的图形是 G-API 标准操作、用户定义操作(命名空间 custom::)和 DNN 推理的混合体。通用函数 cv::gapi::infer<>() 允许在管道内触发推理;要推理的网络作为模板参数指定。示例代码使用了 cv::gapi::infer<>() 的两个版本
- 一个面向帧的版本,用于在输入帧上检测人脸。
- 一个面向 ROI 列表的版本,用于在人脸列表上运行特征点推理——此版本为每个人脸生成一个特征点数组。
有关更多信息,请参阅“人脸分析管道”的 构建 GComputation 部分。
G-API 中的锐化蒙版
图像 \(I\) 的锐化蒙版 \(U\) 定义为
\[U = I - s * L(M(I)),\]
其中 \(M()\) 是中值滤波器,\(L()\) 是拉普拉斯算子,\(s\) 是强度系数。虽然 G-API 没有提供开箱即用的此功能,但可以使用现有的 G-API 操作自然地表达它
const int sigma,
const float strength)
{
return (src - (laplacian * strength));
}
请注意,上面的代码片段是一个使用 G-API 类型定义的常规 C++ 函数。用户可以编写此类函数来简化图形构建;调用时,此函数只是将相关节点放入其使用的管道中。
自定义操作
人脸美化图形大量使用了自定义操作。本章重点介绍最有趣的核心,有关定义操作和在 G-API 中实现核心的一般信息,请参阅 G-API 内核 API。
人脸检测器后处理
人脸检测器的输出使用以下内核转换为人脸数组
using VectorROI = std::vector<cv::Rect>;
{
static void run(
const cv::Mat &inDetectResult,
const float faceConfThreshold,
VectorROI &outFaces)
{
const int kObjectSize = 7;
const int imgCols = inFrame.
size().width;
const int imgRows = inFrame.
size().height;
outFaces.clear();
const int numOfDetections = inDetectResult.
size[2];
const float *data = inDetectResult.
ptr<
float>();
for (int i = 0; i < numOfDetections; i++)
{
const float faceId = data[i * kObjectSize + 0];
if (faceId < 0.f)
{
break;
}
const float faceConfidence = data[i * kObjectSize + 2];
if (faceConfidence > faceConfThreshold)
{
const float left = data[i * kObjectSize + 3];
const float top = data[i * kObjectSize + 4];
const float right = data[i * kObjectSize + 5];
const float bottom = data[i * kObjectSize + 6];
toIntRounded(top * imgRows));
toIntRounded(bottom * imgRows));
outFaces.push_back(
cv::Rect(tl, br) & borders);
}
}
}
};
面部特征点后处理
该算法使用来自 OpenVINO™ Open Model Zoo 的通用面部特征点检测器(详细信息)来推断面部元素(如眼睛、嘴巴和头部轮廓本身)的位置。然而,检测到的特征点本身不足以生成蒙版——此操作需要由闭合轮廓表示的面部感兴趣区域,因此需要进行一些插值才能获得它们。此特征点处理和插值由以下内核执行
{
static void run(const std::vector<Landmarks> &vctPtsFaceElems,
const std::vector<Contour> &vctCntJaw,
std::vector<Contour> &vctElemsContours,
std::vector<Contour> &vctFaceContours)
{
size_t numFaces = vctCntJaw.size();
CV_Assert(numFaces == vctPtsFaceElems.size());
vctElemsContours.reserve(numFaces * 4);
vctFaceContours.reserve(numFaces);
Contour cntFace, cntLeftEye, cntRightEye, cntNose, cntMouth;
cntNose.reserve(4);
for (size_t i = 0ul; i < numFaces; i++)
{
cntLeftEye = getEyeEllipse(vctPtsFaceElems[i][1], vctPtsFaceElems[i][0]);
cntLeftEye.insert(cntLeftEye.end(), {vctPtsFaceElems[i][12], vctPtsFaceElems[i][13],
vctPtsFaceElems[i][14]});
cntRightEye = getEyeEllipse(vctPtsFaceElems[i][2], vctPtsFaceElems[i][3]);
cntRightEye.insert(cntRightEye.end(), {vctPtsFaceElems[i][15], vctPtsFaceElems[i][16],
vctPtsFaceElems[i][17]});
cntNose.clear();
cntNose.insert(cntNose.end(), {vctPtsFaceElems[i][4], vctPtsFaceElems[i][7],
vctPtsFaceElems[i][5], vctPtsFaceElems[i][6]});
cntMouth = getPatchedEllipse(vctPtsFaceElems[i][8], vctPtsFaceElems[i][9],
vctPtsFaceElems[i][10], vctPtsFaceElems[i][11]);
vctElemsContours.insert(vctElemsContours.end(), {cntLeftEye, cntRightEye, cntNose, cntMouth});
cntFace = getForeheadEllipse(vctCntJaw[i][0], vctCntJaw[i][16], vctCntJaw[i][8]);
std::copy(vctCntJaw[i].crbegin(), vctCntJaw[i].crend(), std::back_inserter(cntFace));
vctFaceContours.push_back(cntFace);
}
}
};
该内核接受两个反归一化特征点坐标数组,并返回一个元素闭合轮廓数组和一个面部闭合轮廓数组;换句话说,输出是第一个是需要锐化的图像区域轮廓数组,第二个是需要平滑的轮廓数组。
此处及以下,Contour 是一个点向量。
获取眼部轮廓
使用以下函数估算眼部轮廓
inline int custom::getLineInclinationAngleDegrees(
const cv::Point &ptLeft,
const cv::Point &ptRight)
{
if (residual.
y == 0 && residual.
x == 0)
return 0;
else
return toIntRounded(
atan2(toDouble(residual.
y), toDouble(residual.
x)) * 180.0 /
CV_PI);
}
{
Contour cntEyeBottom;
const cv::Point ptEyeCenter((ptRight + ptLeft) / 2);
const int angle = getLineInclinationAngleDegrees(ptLeft, ptRight);
const int axisX = toIntRounded(
cv::norm(ptRight - ptLeft) / 2.0);
const int axisY = axisX / 3;
static constexpr int kAngEyeStart = 0;
static constexpr int kAngEyeEnd = 180;
cntEyeBottom);
return cntEyeBottom;
}
简而言之,此函数通过基于两个眼角点的半椭圆来恢复眼睛的下半部分。实际上,cv::ellipse2Poly() 用于近似眼部区域,而函数仅根据两个点定义椭圆参数
- 由两个眼部点计算出的椭圆中心和 X 半轴;
- Y 半轴根据平均眼宽是其长度的 1/3 的假设计算得出;
- 开始和结束角度为 0 和 180(请参阅
cv::ellipse() 文档);
- 角度增量:轮廓中要生成的点数;
- 轴的倾斜角度。
在 custom::getLineInclinationAngleDegrees() 函数中使用 atan2() 而非仅使用 atan() 至关重要,因为它允许根据 x 和 y 的符号返回负值,从而即使在人脸倒置的情况下也能获得正确的角度(当然,前提是我们按正确的顺序放置点)。
获取额头轮廓
该函数近似额头轮廓
inline Contour custom::getForeheadEllipse(
const cv::Point &ptJawLeft,
{
Contour cntForehead;
const cv::Point ptFaceCenter((ptJawLeft + ptJawRight) / 2);
const int angFace = getLineInclinationAngleDegrees(ptJawLeft, ptJawRight);
const double jawWidth =
cv::norm(ptJawLeft - ptJawRight);
const int axisX = toIntRounded(jawWidth / 2.0);
const double jawHeight =
cv::norm(ptFaceCenter - ptJawLower);
const int axisY = toIntRounded(jawHeight * 2 / 3.0);
static constexpr int kAngForeheadStart = 180;
static constexpr int kAngForeheadEnd = 360;
config::kAngDelta, cntForehead);
return cntForehead;
}
由于我们只有下颌的特征点,我们必须根据下颌的三个点来获取一个半椭圆:最左边的点、最右边的点和最下面的点。下颌宽度假定等于额头宽度,后者根据左右点计算得出。至于 Y 轴,我们没有直接的点来获取它,而是假设额头高度约为下颌高度的 2/3,这可以从脸部中心(左右点之间的中点)和最低下颌点推断出来。
绘制蒙版
当我们拥有所有需要的轮廓后,我们就可以绘制蒙版了
cv::GMat mskSharp = custom::GFillPolyGContours::on(gimgIn, garElsConts);
config::kGSigma);
cv::GMat mskBlur = custom::GFillPolyGContours::on(gimgIn, garFaceConts);
config::kGSigma);
cv::GMat mskFacesGaussed = mskBlurFinal + mskSharpG;
获取蒙版的步骤是
- 计算“锐化”蒙版
- 填充需要锐化的轮廓;
- 将其模糊以获得“锐化”蒙版(
mskSharpG);
- 计算“双边”蒙版
- 完全填充所有面部轮廓;
- 将其模糊;
- 减去与“锐化”蒙版相交的区域——得到“双边”蒙版(
mskBlurFinal);
- 计算背景蒙版
配置和运行管道
一旦图形完全表达出来,我们就可以最终编译它并在真实数据上运行。G-API 图形编译是 G-API 框架实际理解要使用哪些内核和网络的阶段。此配置通过 G-API 编译参数完成。
DNN 参数
此示例使用 OpenVINO™ 工具包推理引擎后端进行 DL 推理,配置方式如下
{
faceXmlPath,
faceBinPath,
faceDevice
};
{
landmXmlPath,
landmBinPath,
landmDevice
};
每个 cv::gapi::ie::Params<> 对象都与我们在教程早期定义的 G_API_NET() 中指定的网络相关。我们应该将网络类型传递给它,该网络类型在教程早期定义。
网络参数然后被包装在 cv::gapi::NetworkPackage 中
更多详情请参阅“人脸分析管道”的 配置管道部分。
内核包
在此示例中,我们使用了大量自定义内核,此外,我们还使用 Fluid 后端来优化 G-API 标准内核的内存使用(如果适用)。生成的内核包如下所示
custom::GCPULaplacian,
custom::GCPUFillPolyGContours,
custom::GCPUPolyLines,
custom::GCPURectangle,
custom::GCPUFacePostProc,
custom::GCPULandmPostProc,
custom::GCPUGetContours>();
customKernels);
编译流式管道
G-API 在“流式”模式下编译时会优化视频流的执行。
更多信息请参阅“人脸分析管道”的 配置管道部分。
运行流式管道
为了运行 G-API 流式管道,我们需要做的就是指定输入视频源,调用 cv::GStreamingCompiled::start(),然后获取管道的处理结果
if (parser.has("input"))
{
}
auto out_vector =
cv::gout(imgBeautif, imgShow, vctFaceConts,
vctElsConts, vctRects);
avg.start();
{
if (!stream.
try_pull(std::move(out_vector)))
{
else continue;
}
frames++;
if (flgLandmarks == true)
{
config::kClrYellow);
config::kClrYellow);
}
if (flgBoxes == true)
for (auto rect : vctRects)
cv::imshow(config::kWinFaceBeautification, imgBeautif);
}
一旦结果准备好并且可以从管道中获取,我们就将其显示在屏幕上并处理 GUI 事件。
有关更多详细信息,请参阅“人脸分析管道”教程的 运行管道部分。
结论
本教程有两个目标:展示 G-API 在 OpenCV 4.2 中引入的新功能,并对示例人脸美化算法进行基本理解。
算法应用结果
人脸美化示例
在测试机(Intel® Core™ i7-8700)上,G-API 优化的视频管道的性能优于其串行(非管道化)版本 **2.7** 倍——这意味着对于这样一个非平凡的图形,适当的管道化可以带来近 3 倍的性能提升。