图像毛玻璃效果与图像模糊算法

本文介绍图像毛玻璃效果算法与一些常见的图像模糊算法, 包括原理介绍, 算法实现, 效率测试. 所有算法均提供基于 Qt 的实现方式与基于 OpenCV 的实现方式(或是 OpenCV 原生功能介绍). 持续更新.

毛玻璃效果

毛玻璃效果与模糊效果类似, 但前者 “颗粒感” 更重, 就像透过一层毛玻璃看图像一样. 具体效果见下:

原图(左) 与 毛玻璃效果(右).

grass_style_image.png

基础理论

毛玻璃效果实现起来比较简单, 理论性能也高于普通的模糊算法. 具体来说就是使用像素点周边范围内随机一个像素点代替自身即可.

基于这个原理, 实现毛玻璃算法遍历图像的每个像素点, 处理每一个像素点的时间复杂度均为 O(1) , 因此总体时间复杂度为 :

O(M * N) 其中 M 与 N 是图像维数

在取临近像素点时, 涉及到 “临近” 的范围问题. 通过限制随机数取值的半径可以获得不同的最终效果, 总的来说半径越大越”模糊”.

原图(左上) ; 毛玻璃效果-半径 10 (中) ; 毛玻璃效果-半径 50 (右下).

grass_style_image_radius.png

基于 Qt 实现

实现方式遵照原理. 额外需要考虑一些细节, 比如在图像边缘处取邻域像素点时要注意坐标不能超出图像范围. 代码见下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static QImage addGrassStyle(const QImage& source,
int minX, int minY, int maxX, int maxY,
int radius = 10) {
auto bottomRight = source.rect().bottomRight();

if (minX < 0) { minX = 0; }
if (minY < 0) { minY = 0; }
if (maxX > bottomRight.rx()) { maxX = bottomRight.rx(); }
if (maxY > bottomRight.ry()) { maxY = bottomRight.ry(); }

QImage result = source.copy();
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
auto randNum = rand() % (radius + 1);
auto nearX = x + randNum < bottomRight.rx() ? x + randNum : x - randNum;
auto nearY = y + randNum < bottomRight.ry() ? y + randNum : y - randNum;
result.setPixel(x, y, source.pixel(nearX, nearY));
}
}

return result;
}

基于 OpenCV 实现

OpenCV 的实现与 Qt 大同小异. OpenCV 的图像处理中像素的坐标采用 (行, 列) 的方式表示, 与 Qt 的 (X, Y) 表示方式不同, 使用时需要注意. 具体代码见下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static cv::Mat addGrassStyle(const cv::Mat& source,
int minRow, int minCol, int maxRow, int maxCol,
int radius = 10) {
if (minRow < 0) { minRow = 0; }
if (minCol < 0) { minCol = 0; }
if (maxRow > source.rows) { maxRow = source.rows; }
if (maxCol > source.cols) { maxCol = source.cols; }

cv::RNG rng;
cv::Mat result = source.clone();

for (int row = minRow; row < maxRow; row++) {
for (int col = minCol; col < maxCol; col++) {
auto randNum = rng.uniform(0, radius);
auto nearRow = row + randNum < source.rows ? row + randNum : row - randNum;
auto nearCol = col + randNum < source.cols ? col + randNum : col - randNum;
result.at<cv::Vec3b>(row, col) = source.at<cv::Vec3b>(nearRow, nearCol);
//auto resultPoint = result.ptr<cv::Vec3b>(row, col);
//auto sourcePoint = source.ptr<cv::Vec3b>(nearRow, nearCol);
//resultPoint[0] = sourcePoint[0]; // B
//resultPoint[1] = sourcePoint[1]; // G
//resultPoint[2] = sourcePoint[2]; // R
}
}

return result;
}

注意注释部分, 比起使用 .at 对像素点进行操作, 注释中的使用 .ptr 对像素点取指针并分别对 BGR 通道进行操作的效率更高. 因为前者需要拷贝构造. 在 OpenCV 中处理像素的方式有很多, 主要差异在于获取与设置像素信息的方式. 常用的方式有三种:

  • 如 cv::Mat::at<Type>(row, col) 等 取引用
  • 如 cv::Mat::ptr<Type>(row, col) 等 取指针
  • 如 cv::Mat::begin<Type>() 等 取迭代器

理论上取指针最快, 取迭代器的方式最慢. 前者的耗时大约是后者的 1/10. 但经过测试, 在编译时开启 -O2 级别的优化后(VS Release 模式默认 -O2), 无论何种操作方式效率都没有差别.

效率对比

使用一张 1680x1050 分辨率的图像进行测试, 分别使用上述两种实现方式对其进行图像处理. 取 100 次的结果平均值如下:

毛玻璃算法测试(单位:ms) Qt 实现 OpenCV 实现
Debug 模式 367.84 109.99
Release 模式(-O2优化) 128.08 17.55

显然 OpenCV 的效率是更高的. 具体应用中发现 Debug 模式下使用基于 Qt 的实现方式处理摄像头画面造成的程序效率低下是肉眼可见的, 而使用基于 OpenCV 的实现方式则没有明显的”卡顿感”.


高斯模糊 (Gaussian Blur)

高斯模糊是一种经典的模糊算法, 这种算法的模糊品质很好, 但性能一般. 由于算法使用到了高斯的正态分布, 因此该算法被冠名为高斯模糊算法.

原图(左) 与 高斯模糊效果图(右)

grass_style_image_radius.png

基础理论

高斯模糊将邻域的像素点的值按权重叠加起来作为当前像素点的值. 这个”邻域的权重分布”是按照正态分布(高斯分布)为依据的, 被称作 高斯核 (Gaussian Kernel) , 它是一个符合正态分布且边长为奇数的二维矩阵. 矩阵中心点的值为当前所计算的像素点拥有的权重, 同理矩阵其他位置的每一个值即为对应邻域像素点的权重. 一个典型的 高斯核 如下:

1
2
3
4
5
constexpr int kernel[5][5] = {{1, 4 , 6 , 4 , 1},
{4, 16, 24, 16, 4},
{6, 24, 36, 24, 6},
{4, 16, 24, 16, 4},
{1, 4 , 6 , 4 , 1}}; // * 1 / 256

针对每一个需要处理的像素点, 遍历高斯核取出邻域内像素点的值并进行累加, 最后除以总权重. 也就是整体上我们需要一个四层的嵌套循环. 因此时间复杂度为:

O(m * n * M * N) 其中 M 与 N 是图像的维数, m 与 n 是滤波器的维数.

高斯模糊也可以在二维图像上对两个独立的一维空间进行分别计算, 即满足线性可分(Linearly separable). 也就是说我们可以对一个像素点 X 轴与 Y 轴两个一维空间上分别进行一维的高斯模糊运算从而得到二维高斯模糊的效果. 这么做的好处是时间复杂度降低了, 优化后的时间复杂度为:

O(m * M * N) + O(n * M * N)

由于只需要进行一维空间上的计算, 因此我们实际所需的高斯核也只需要一维的, 如下:

1
constexpr int kernel[5] = {1, 4 , 6 , 4 , 1};  // * (1 / 2 * 16)

基于 Qt 实现

基础的高斯模糊算法如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static QImage addGaussianBlur(const QImage& source,
int minX, int minY, int maxX, int maxY) {
constexpr int kernel[5][5] = {{1, 4 , 6 , 4 , 1},
{4, 16, 24, 16, 4},
{6, 24, 36, 24, 6},
{4, 16, 24, 16, 4},
{1, 4 , 6 , 4 , 1}}; // 高斯核
constexpr int diameter = sizeof(kernel[0]) / sizeof(int);
constexpr int radius = diameter / 2;
int totalWeight = 0;
for (int y = 0; y < diameter; y++) {
for (int x = 0; x < diameter; x++) {
totalWeight += kernel[y][x];
}
}

// 边界处理, 只计算邻域可覆盖完整高斯核的区域
if (minX < radius) { minX = radius; }
if (minY < radius) { minY = radius; }
int gapX = source.width() - maxX;
int gapY = source.height() - maxY;
if (gapX < radius) { maxX -= radius; }
if (gapY < radius) { maxY -= radius; }

QImage result = source.copy();
for (int x = minX; x < maxX; x++) {
for (int y = minY; y < maxY; y++) {
int r = 0;
int g = 0;
int b = 0;
for (int nearX = -radius; nearX <= radius; nearX++) {
for (int nearY = -radius; nearY <= radius; nearY++) {
QRgb color = source.pixel(x + nearX, y + nearY);
r += qRed(color) * kernel[radius + nearX][radius + nearY];
g += qGreen(color) * kernel[radius + nearX][radius + nearY];
b += qBlue(color) * kernel[radius + nearX][radius + nearY];
}
}
result.setPixel(x, y, qRgb(r / totalWeight,
g / totalWeight,
b / totalWeight)); // 记得要除以总权重
}
}
return result;
}

经过优化有, 只在一维空间内进行计算的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
static QImage addGaussianBlurEx(const QImage& source,
int minX, int minY, int maxX, int maxY) {
constexpr int kernel[5] = {1, 4 , 6 , 4 , 1}; // 一维空间高斯核
constexpr int radius = sizeof(kernel) / sizeof(int) / 2;
int totalWeight = 0;
for (int weight : kernel) {
totalWeight += weight;
}
totalWeight *= 2; // 实际上有 X/Y 轴两个方向上的高斯核, 因此总权重是两倍

if (minX < radius) { minX = radius; }
if (minY < radius) { minY = radius; }
int gapX = source.width() - maxX;
int gapY = source.height() - maxY;
if (gapX < radius) { maxX -= radius; }
if (gapY < radius) { maxY -= radius; }

QImage result = source.copy();

for (int x = minX; x < maxX; x++) {
for (int y = minY; y < maxY; y++) {
int r = 0;
int g = 0;
int b = 0;
for (int nearPos = -radius; nearPos <= radius; nearPos++) {
QRgb colorX = source.pixel(x + nearPos, y); // X 轴方向
r += qRed(colorX) * kernel[radius + nearPos];
g += qGreen(colorX) * kernel[radius + nearPos];
b += qBlue(colorX) * kernel[radius + nearPos];

QRgb colorY = source.pixel(x, y + nearPos); // Y 轴方向
r += qRed(colorY) * kernel[radius + nearPos];
g += qGreen(colorY) * kernel[radius + nearPos];
b += qBlue(colorY) * kernel[radius + nearPos];
}
result.setPixel(x, y, qRgb((r / totalWeight),
(g / totalWeight),
(b / totalWeight)));
}
}

return result;
}

这里的边界处理还可以优化, 可以对邻域无法覆盖完整高斯核的部分使用反向对应的邻近像素替代.

OpenCV 的高斯模糊算法

OpenCV 提供了计算高斯模糊的方法, 方法原型如下:

1
2
3
4
// namespace cv
CV_EXPORTS_W void GaussianBlur(InputArray src, OutputArray dst, Size ksize,
double sigmaX, double sigmaY = 0,
int borderType = BORDER_DEFAULT );

参数介绍:

  • InputArray src : 原图像
  • OutputArray dst : 输出图像
  • Size ksize : 高斯核尺寸, 宽高需相等, 边长需为奇数. 若取值为 0, 则自动根据 sigmaX , sigmaY 计算.
  • double sigmaX : X 轴上高斯核的标准差 σ, 若为负数或 0 则自动根据 ksize 计算
  • double sigmaY : Y 轴上高斯核的标准差 σ, 若为负数或 0 则自动根据 ksize 计算
  • int borderType : 该选项用于确定边界处理的方式, 默认情况下使用反向对应的邻近像素替代图像外的点.

InputArrayOutputArray 是 OpenCV 用于兼容不同类型参数抽象层, 本质上是一个外观模式(Facade Pattern) 的实现. 以这个方法为例, 实际使用中 srcdst参数都应该直接传入cv::Mat` 对象. OpenCV 为其提供了合理的隐式转换方法.

这里用到了两个标准差 σ 参数, 这就是正态分布中的标准差. OpenCV 会根据 sigmaXsigmaY 两个参数分别先算出两个维度上的一维高斯核, 再进行计算. 可见 OpenCV 采用了优化后的算法.

效率对比

使用一张 1680x1050 分辨率的图像进行测试, 分别使用上述三种实现方式对其进行图像处理. 取 100 次的结果平均值如下:

高斯模糊算法测试(单位:ms) Qt 实现(原始版本) Qt 实现(优化版本) OpenCV 实现(σ=1.1)
Debug 模式 891.07 427.79 81.69
Release 模式(-O2优化) 475.45 223.8 2.24

这里 OpenCV 的实现版本快的离谱, 经过反复测试, 当 σ=24 的时候, OpenCV 版本的效率才与我们自己实现的优化版本效率相近, 此时 -O2 优化模式下平均耗时 238.98 ms. 不得不说 OpenCV 真是厉害, 有机会看一下这个函数的的源码实现再更新.