OpenCV 4.11.0
开源计算机视觉库
加载中…
搜索中…
未找到匹配项
使用 G-API 实现人脸美化算法

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

引言

在本教程中,您将学习:

  • 示例人脸美化算法的基础知识;
  • 如何在 G-API 管线中推断不同的网络;
  • 如何在视频流上运行 G-API 管线。

前提条件

此示例需要:

  • 安装了 GNU/Linux 或 Microsoft Windows 的 PC(支持 Apple macOS,但未经测试);
  • 使用英特尔® OpenVINO™ 工具包构建的 OpenCV 4.2 或更高版本(使用英特尔® 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() 定义。

G_API_NET(FaceDetector, <cv::GMat(cv::GMat)>, "face_detector");
G_API_NET(LandmDetector, <cv::GMat(cv::GMat)>, "landm_detector");

要获取更多信息,请参阅“人脸分析管线”教程中描述的 声明深度学习拓扑结构

描述处理图

以下代码为上述算法生成一个图:

cv::GComputation pipeline([=]()
{
cv::GMat gimgIn; // 输入
GArrayROI garRects = custom::GFacePostProc::on(faceOut, gimgIn, config::kConfThresh); // 后处理
cv::GArray<Landmarks> garElems; // |
cv::GArray<Contour> garJaws; // |输出数组
std::tie(garElems, garJaws) = custom::GLandmPostProc::on(landmOut, garRects); // 后处理
cv::GArray<Contour> garElsConts; // 面部元素
cv::GArray<Contour> garFaceConts; // 全部面部
std::tie(garElsConts, garFaceConts) = custom::GGetContours::on(garElems, garJaws); // 插值
cv::GMat mskSharp = custom::GFillPolyGContours::on(gimgIn, garElsConts); // |
cv::GMat mskSharpG = cv::gapi::gaussianBlur(mskSharp, config::kGKernelSize, // |
config::kGSigma); // |
cv::GMat mskBlur = custom::GFillPolyGContours::on(gimgIn, garFaceConts); // |
cv::GMat mskBlurG = cv::gapi::gaussianBlur(mskBlur, config::kGKernelSize, // |
config::kGSigma); // |绘制掩膜
// mask() 中的第一个参数是 Blur,因为我们想要从 // |
// BlurG 中减去下一步: // |
cv::GMat mskBlurFinal = mskBlurG - cv::gapi::mask(mskBlurG, mskSharpG); // |
cv::GMat mskFacesGaussed = mskBlurFinal + mskSharpG; // |
cv::GMat mskFacesWhite = cv::gapi::threshold(mskFacesGaussed, 0, 255, cv::THRESH_BINARY); // |
cv::GMat mskNoFaces = cv::gapi::bitwise_not(mskFacesWhite); // |
cv::GMat gimgBilat = custom::GBilatFilter::on(gimgIn, config::kBSize,
config::kBSigmaCol, config::kBSigmaSp);
cv::GMat gimgSharp = custom::unsharpMask(gimgIn, config::kUnshSigma,
config::kUnshStrength);
// 应用掩码
// 应使用自定义函数 mask3C() 代替 gapi::mask()
// 因为 mask() 只提供 CV_8UC1 源 (而我们有 CV_8U3C)
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;
return cv::GComputation(cv::GIn(gimgIn), cv::GOut(gimgBeautif,
cv::gapi::copy(gimgIn),
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 操作自然地表示

inline cv::GMat custom::unsharpMask(const cv::GMat &src,
const int sigma,
const float strength)
{
cv::GMat blurred = cv::gapi::medianBlur(src, sigma);
cv::GMat laplacian = custom::GLaplacian::on(blurred, CV_8U);
return (src - (laplacian * strength));
}

请注意,上面的代码片段是用 G-API 类型定义的常规 C++ 函数。用户可以编写这样的函数来简化图的构建;调用此函数时,它只会将相关的节点放入其所使用的管道中。

自定义操作

面部美化图广泛使用自定义操作。本章重点介绍最有趣的内核,有关在 G-API 中定义操作和实现内核的常规信息,请参阅 G-API 内核 API

人脸检测后处理

人脸检测器的输出使用以下内核转换为面部数组

using VectorROI = std::vector<cv::Rect>;
GAPI_OCV_KERNEL(GCPUFacePostProc, GFacePostProc)
{
static void run(const cv::Mat &inDetectResult,
const cv::Mat &inFrame,
const float faceConfThreshold,
VectorROI &outFaces)
{
const int kObjectSize = 7;
const int imgCols = inFrame.size().width;
const int imgRows = inFrame.size().height;
const cv::Rect borders({0, 0}, inFrame.size());
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];
// 我们可以根据 `conf` 字段来裁剪检测结果
// 以避免检测器的错误。
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];
// 这些是归一化坐标,介于 0 和 1 之间;
// 要获取真实的像素坐标,我们应该分别将其乘以
// 图像大小。
cv::Point tl(toIntRounded(left * imgCols),
toIntRounded(top * imgRows));
cv::Point br(toIntRounded(right * imgCols),
toIntRounded(bottom * imgRows));
outFaces.push_back(cv::Rect(tl, br) & borders);
}
}
}
};

面部地标后处理

该算法使用OpenVINO™ Open Model Zoo中的通用面部标志检测器(详情)推断面部元素(如眼睛、嘴巴和头部轮廓本身)的位置。但是,检测到的标志点本身不足以生成遮罩——此操作需要由闭合轮廓表示的面部感兴趣区域,因此应用了一些插值来获得它们。此标志点处理和插值由以下内核执行

GAPI_OCV_KERNEL(GCPUGetContours, GGetContours)
{
static void run(const std::vector<Landmarks> &vctPtsFaceElems, // 面部元素的18个标志点
const std::vector<Contour> &vctCntJaw, // 下巴的17个标志点
std::vector<Contour> &vctElemsContours,
std::vector<Contour> &vctFaceContours)
{
size_t numFaces = vctCntJaw.size();
CV_Assert(numFaces == vctPtsFaceElems.size());
CV_Assert(vctElemsContours.size() == 0ul);
CV_Assert(vctFaceContours.size() == 0ul);
// vctFaceElemsContours 将存储在输入图像中找到的所有面部元素轮廓
// 即每个检测到的面部有4个元素(两只眼睛、鼻子、嘴巴)
vctElemsContours.reserve(numFaces * 4);
// vctFaceElemsContours 将存储在输入图像中找到的所有面部轮廓
vctFaceContours.reserve(numFaces);
Contour cntFace, cntLeftEye, cntRightEye, cntNose, cntMouth;
cntNose.reserve(4);
for (size_t i = 0ul; i < numFaces; i++)
{
// 面部元素轮廓
// 左眼
// 用半椭圆近似下眼睑轮廓(使用眼部点),并存储在 cntLeftEye 中
cntLeftEye = getEyeEllipse(vctPtsFaceElems[i][1], vctPtsFaceElems[i][0]);
// 按顺时针方向添加左眉毛
cntLeftEye.insert(cntLeftEye.end(), {vctPtsFaceElems[i][12], vctPtsFaceElems[i][13],
vctPtsFaceElems[i][14]});
// 右眼
// 用半椭圆近似下眼睑轮廓(使用眼部点),并存储在 vctRightEye 中
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]});
// 嘴巴
// 用两个半椭圆近似嘴巴轮廓(使用嘴巴点),并存储在 vctMouth 中
cntMouth = getPatchedEllipse(vctPtsFaceElems[i][8], vctPtsFaceElems[i][9],
vctPtsFaceElems[i][10], vctPtsFaceElems[i][11]);
// 将所有元素存储到向量中
vctElemsContours.insert(vctElemsContours.end(), {cntLeftEye, cntRightEye, cntNose, cntMouth});
// 面部轮廓
// 用半椭圆近似额头轮廓(使用下巴点),并存储在 vctFace 中
cntFace = getForeheadEllipse(vctCntJaw[i][0], vctCntJaw[i][16], vctCntJaw[i][8]);
// 椭圆按顺时针方向绘制,但下巴轮廓点则相反,因此需要使用反向迭代器将
// cntJaw 从末尾推送到开头
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)
{
const cv::Point residual = ptRight - ptLeft;
if (residual.y == 0 && residual.x == 0)
return 0;
else
return toIntRounded(atan2(toDouble(residual.y), toDouble(residual.x)) * 180.0 / CV_PI);
}
inline Contour custom::getEyeEllipse(const cv::Point &ptLeft, const cv::Point &ptRight)
{
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);
// 根据研究,平均而言,眼睛的Y轴大约是
// X轴的1/3。
const int axisY = axisX / 3;
// 我们需要椭圆的下半部分
static constexpr int kAngEyeStart = 0;
static constexpr int kAngEyeEnd = 180;
cv::ellipse2Poly(ptEyeCenter, cv::Size(axisX, axisY), angle, kAngEyeStart, kAngEyeEnd, config::kAngDelta,
cntEyeBottom);
return cntEyeBottom;
}

简而言之,此函数基于左眼角和右眼角的两个点,通过半椭圆恢复眼睛的下部。实际上,cv::ellipse2Poly()用于逼近眼部区域,该函数仅基于两个点定义椭圆参数

  • 通过两个眼点计算椭圆中心和X半轴;
  • 根据平均眼宽是其长度的1/3的假设计算Y半轴;
  • 起始角和结束角分别为0和180(参见cv::ellipse()文档);
  • 角度 delta:轮廓中要生成的点数;
  • 轴线的倾斜角度。

在函数 `custom::getLineInclinationAngleDegrees()` 中使用 `atan2()` 而不是 `atan()` 至关重要,因为它允许根据 `x` 和 `y` 的符号返回负值,因此即使在面部朝下排列的情况下(当然,如果我们按正确的顺序放置点),我们也能得到正确的角度。

获取额头轮廓

该函数逼近额头轮廓

inline Contour custom::getForeheadEllipse(const cv::Point &ptJawLeft,
const cv::Point &ptJawRight,
const cv::Point &ptJawLower)
{
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);
// 根据研究,平均而言,额头大约是
// 下颌的 2/3
const int axisY = toIntRounded(jawHeight * 2 / 3.0);
// 我们需要椭圆的上半部分
static constexpr int kAngForeheadStart = 180;
static constexpr int kAngForeheadEnd = 360;
cv::ellipse2Poly(ptFaceCenter, cv::Size(axisX, axisY), angFace, kAngForeheadStart, kAngForeheadEnd,
config::kAngDelta, cntForehead);
return cntForehead;
}

由于我们检测到的地标中只有下颌点,我们必须根据下颌的三个点(最左点、最右点和最低点)获取半椭圆。假设下颌宽度等于额头宽度,后者使用最左点和最右点计算得出。对于 Y 轴,我们没有直接获取它的点,而是假设额头高度约为下颌高度的 2/3,这可以从面部中心(最左点和最右点之间的中间点)和下颌最低点计算得出。

绘制遮罩

当我们获得所有需要的轮廓后,就可以绘制遮罩了

cv::GMat mskSharp = custom::GFillPolyGContours::on(gimgIn, garElsConts); // |
cv::GMat mskSharpG = cv::gapi::gaussianBlur(mskSharp, config::kGKernelSize, // |
config::kGSigma); // |
cv::GMat mskBlur = custom::GFillPolyGContours::on(gimgIn, garFaceConts); // |
cv::GMat mskBlurG = cv::gapi::gaussianBlur(mskBlur, config::kGKernelSize, // |
config::kGSigma); // |绘制掩膜
// mask() 中的第一个参数是 Blur,因为我们想要从 // |
// BlurG 中减去下一步: // |
cv::GMat mskBlurFinal = mskBlurG - cv::gapi::mask(mskBlurG, mskSharpG); // |
cv::GMat mskFacesGaussed = mskBlurFinal + mskSharpG; // |
cv::GMat mskFacesWhite = cv::gapi::threshold(mskFacesGaussed, 0, 255, cv::THRESH_BINARY); // |
cv::GMat mskNoFaces = cv::gapi::bitwise_not(mskFacesWhite); // |

获取遮罩的步骤是

  • “锐化”遮罩计算
    • 填充需要锐化的轮廓;
    • 模糊处理以获得“锐化”遮罩 (mskSharpG);
  • “双边”遮罩计算
    • 完全填充所有面部轮廓;
    • 模糊处理;
    • 减去与“锐化”遮罩相交的区域——并获得“双边”遮罩 (mskBlurFinal);
  • 背景遮罩计算
    • 添加前两个遮罩
    • 将结果的所有非零像素设置为 255 (通过 `cv::gapi::threshold()`)
    • 反转输出 (通过 `cv::gapi::bitwise_not`) 以获得背景遮罩 (mskNoFaces)。

配置和运行管道

一旦图形完全表达出来,我们就可以最终编译它并在真实数据上运行。G-API 图形编译是 G-API 框架实际理解要使用哪些内核和网络的阶段。此配置通过 G-API 编译参数进行。

DNN 参数

此示例使用 OpenVINO™ 工具包推理引擎后端进行 DL 推理,其配置方式如下

{
/*std::string*/ faceXmlPath,
/*std::string*/ faceBinPath,
/*std::string*/ faceDevice
};
{
/*std::string*/ landmXmlPath,
/*std::string*/ landmBinPath,
/*std::string*/ landmDevice
};

每个 `cv::gapi::ie::Params<>` 对象都与其模板参数中指定的网络相关。我们应该在那里传递我们在教程开头 `G_API_NET()` 中定义的网络类型。

网络参数然后包装在 `cv::gapi::NetworkPackage` 中

auto networks = cv::gapi::networks(faceParams, landmParams);

更多详细信息请参见“人脸分析管道”(配置管道 部分)。

内核包

在此示例中,我们使用了许多自定义内核,此外,我们还使用 Fluid 后端来优化 G-API 标准内核的内存(在适用情况下)。生成的内核包如下所示

auto customKernels = cv::gapi::kernels<custom::GCPUBilateralFilter,
custom::GCPULaplacian,
custom::GCPUFillPolyGContours,
custom::GCPUPolyLines,
custom::GCPURectangle,
custom::GCPUFacePostProc,
custom::GCPULandmPostProc,
custom::GCPUGetContours>();
customKernels);

编译流式管道

以“流式”模式编译时,G-API 会针对视频流优化执行。

cv::GStreamingCompiled stream = pipeline.compileStreaming(cv::compile_args(kernels, networks));

更多详细信息请参见“人脸分析管道”(配置管道 部分)。

运行流式管道

为了运行 G-API 流式管道,我们只需要指定输入视频源,调用cv::GStreamingCompiled::start(),然后获取管道处理结果。

if (parser.has("input"))
{
}
auto out_vector = cv::gout(imgBeautif, imgShow, vctFaceConts,
vctElsConts, vctRects);
stream.start();
avg.start();
while (stream.running())
{
if (!stream.try_pull(std::move(out_vector)))
{
// 使用 try_pull() 获取数据。
// 如果没有数据,则刷新UI(并处理按键)
if (cv::waitKey(1) >= 0) break;
else continue;
}
frames++;
// 必要时绘制人脸框和关键点
if (flgLandmarks == true)
{
cv::polylines(imgShow, vctFaceConts, config::kClosedLine,
config::kClrYellow);
cv::polylines(imgShow, vctElsConts, config::kClosedLine,
config::kClrYellow);
}
if (flgBoxes == true)
for (auto rect : vctRects)
cv::rectangle(imgShow, rect, config::kClrGreen);
cv::imshow(config::kWinInput, imgShow);
cv::imshow(config::kWinFaceBeautification, imgBeautif);
}

一旦结果准备好并可以从管道中提取,我们就会将其显示在屏幕上并处理GUI事件。

更多详情,请参阅“人脸分析管道”教程中的运行管道部分。

结论

本教程有两个目标:展示OpenCV 4.2中引入的G-API全新功能的使用,并对示例人脸美化算法进行基本讲解。

算法应用的结果

人脸美化示例

在测试机器(Intel® Core™ i7-8700)上,G-API优化的视频管道性能比其串行(非流水线)版本高出2.7倍——这意味着对于这样一个非平凡的图,正确的流水线处理可以将性能提高近3倍。