数字图像处理大作业记录-边缘检测与阈值分割的Python实现及利用PyQt5的GUI开发

用 python 可以不用管数据类型,而用 .net 又可以方便的进行GUI开发。两者结合起来的话是多么的棒啊。可惜我太菜了结合不了。

前言

这学期开的数字图像处理也有实验,实验项目是边缘检测和阈值开发。老师推荐的是用 .net 来做。但感觉太麻烦了,所以问了老师,得到了能用 python 的许可。

不过看起来老师还是希望有个 GUI 界面的,那就试试 PyQt 怎么样咯。刚好也想折腾一下。

正文

写的代码各种文盲,格式乱飞希望不要介意,有疑问的地方可以和我交流。
这个作业的项目地址 :
数字图像处理作业

安装库

用的包方面看我的那个 requirements.txt就行了。

1
2
3
4
qimage2ndarray==1.8.3
opencv_python==4.2.0.34
numpy==1.18.2
PyQt5==5.14.2

用QT Designer设计UI

在GUI方面我用的是QT Designer来辅助设计,这样所见即所得的体验很好。
哦对了,要QT Desinger的安装使用和汉化如果有需求的话可以参考我的这篇文章
打开后大概是这个样子的,这里我们一般用 Main Window 这一项,因为这个会自动附带上工具栏和状态栏,一般还是比较好用的

然后就是实际使用啦,实际上的话和利用VS开发差不了多少,都是拖控件然后命名控件,设置控件的各种属性。

对于开发的话有个小的建议就是把UI界面和实际的业务逻辑分开。就是说UI界面只管各种界面的属性,而不涉及实际的操作的函数。这样在维护的时候很方便。

总之最后我是设计成了这样,能用就行了。

实际运行时的效果大概是这样的。

最后保存你设计的UI文件就可以了,现在我们保存的是.ui文件,并不能直接用于python编程,所以需要使用PyQt自带的程序pyuic5.exe来实现将.ui文件转换为.py文件。

具体的方法如下,打开终端然后输入以下命令

1
你的Python的文件夹\Scripts\pyuic5.exe -o 目标文件保存地址 UI文件保存路径

在实例中大概是这个样子的,我这里是在.ui文件所在的文件夹启动了终端,然后把生成的.py文件保存在了和.ui文件相同的文件夹里面并且命名为MainWindow.py

1
D:\DevEnv\python\python381\Scripts\pyuic5.exe -o MainWindow.py .\UI.ui

我推荐把这个写成.bat或者.sh的脚本文件,来方便每次修改完UI之后都可以通过直接调用脚本来进行转换。

注册响应函数

在设计完UI之后,我们就新建一个.py程序来实现业务逻辑吧。新建一个类继承之前的UI文件的类和QMainWindow这个类。

继承完了之后写一个初始化,然后在这个初始化的函数里面注册响应函数。

注册响应函数的大概格式是这样的

1
self.控件名.触发的消息类型.connect(响应消息的函数)

实际写的话大概是这样的

1
self.actionOpen_File.triggered.connect(self.openfile)

这里的话我是在这个类里面写了一个 openfile 的函数来实现相应的功能。要注意这里调用函数的时候不需要写括号,就是如同下面这样的形式

1
self.actionOpen_File.triggered.connect(self.openfile())

如果这样写了的话会报错 TypeError: argument 1 has unexpected type ‘NoneType’ ,原理大概是这个响应并不期待有返回值?之前写作业的时候忙着解决问题就没有仔细看原理了,这里有StackOverFlow的讨论的链接,感兴趣的可以看看。

保存长高比的情况下的最大缩放

这个问题主要是读入的图像大小和宽高比都不同,但我们想要把它完整地显示出来,这个时候我们就得找到一个合适的缩放比例来把
原图像完整的放入目标窗口中。

其实这个问题可以化简成给定目标窗口的宽和高,让你求按原图像放进去,最大能放多少。

实际上我们可以很简单的推出来,要达到最大的时候,原图像的宽或者高必然有一个等于目标窗口的宽或高。因为没到的话就可以说明还没到最大的缩放。

这里我们可以用极限法来试着看看

定义原图像为 srcImg ,目标窗口为 tarWindow

一种极限情况

当满足下面的情况的时候
srcImg.h = 100000
srcImg.w = 1
tarWindow.h = 1
tarWindow.w = 100000
原图像为一个竖着的图,目标窗口为一个横着的图。这个时候要把原图像按原比例放进目标窗口的话,必然有

srcImg.h = tarWindow.h

同时还可以得到

srcImg.w = srcImg.w * tarWindow.h/srcImg.h (这里的srcImg.h的值是更新前的值,这样是通过缩放比例放大)

这个时候原图像的高等于目标窗口的高,而原图像的宽可以通过缩放比例,或者原图像原本的比例来求得。

另外一种极限情况

当满足下面的情况的时候
srcImg.h = 1
srcImg.w = 100000
tarWindow.h = 100000
tarWindow.w = 1
原图像是一个横着的图,目标窗口是一个竖着的图,这个时候要把原图像按原比例放进目标窗口的话,必然有

srcImg.w = tarWindow.w

同时还可以得到

srcImg.h = srcImg.h * tarWindow.w/srcImg.w (这里的srcImg.w的值是更新前的值,这样是通过缩放比例来放大)

这个时候原图像的宽等于目标窗口的宽,而原图像的高可以通过缩放比例,或者原图像原本的比例来求得。

算法流程

当然这个只是通过极限法推出来的。实际情况中应该怎么判断呢?我给个我的解决方案。

  1. p1 = tarWindow.w/srcImg.w
  2. p2 = tarWindow.h/srcImg.h
  3. p = p1 < p2 ? p1 : p2
  4. newImg.h = srcImg.h * p
  5. newImg.w = srcImg.w * p

简单地说就是求出两种缩放比例的值,分别是根据高或者宽缩放,然后实际应用的值就是取两者之中小的那个。这样就可以在不超过目标窗口的大小的情况下获得最大的缩放。

为什么要这样求呢?
我们先假设原图像的长宽都小于目标窗口的长宽,这个时候我们把原图像从小到大一点点的放大,当第一次接触到目标窗口的边的时候,
你就发现这个时候已经是最大缩放了,你再继续放大的话就会超出目标窗口的限制了。

而当原图像的长宽都大于目标窗口的时候,这时我们将原图像一点点的缩小,当第一次接触到目标窗口的时候,目标窗口依然被你包裹着,
原图像并没有完全显示在目标窗口中。这个时候我们要达到最大缩放的话就得用小的那个情况了。

综合以上的两种情况,很轻松的就可以得到与我的流程相同的结果。

我推导的时候用了Geogebra来帮忙推导,可视化的推导对于没有空间想象力的人来说真是方便。这是文件

读入并显示图片

我这里的实现原理是通过 OpenCV 的 imread 读入图像,然后把OpenCV保存的Numpy格式的数据转换成QT的QImage的数据类型。
显示图像的时候是把图像先放入 QGraphicsScene() 中,然后把这个QGraphicsScene()设置为对应的QGraphicsView()的 Scene 。

这里我转化三通道的图没问题,但单通道的灰度图就不知道怎么的就有错,具体的表现形式是灰度图会被转化成没有意义的黑色条纹。

我各种搜索都没找到解决方法,最后看到有人提到了有qimage2ndarray这个包我才搞定了显示灰度图的问题。

我的显示是这个样子的

1
2
3
4
5
6
7
# 将OpenCV格式储存的图片转换为QT可处理的图片类型
qimg = self.cvPic2Qimg(tmpImg)
# 将图片放入图片显示窗口
scene = QGraphicsScene()
scene.addPixmap(QPixmap.fromImage(qimg))
self.picview_source.setScene(scene)
self.hasOpen = True

转换array数组到qiamge类

这里我直接把我的函数复制过来吧

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
def cvPic2Qimg(self, img):
"""
将用 opencv 读入的图像转换成qt可以读取的图像

========== =====================
序号 支持类型
========== =================
1 灰度图 Gray
2 三通道的图 BGR顺序
3 四通道的图 BGRA顺序
========= ===================
"""
if (len(img.shape)==2):
# 读入灰度图的时候
image = array2qimage(img)
elif (len(img.shape)==3):
# 读入RGB或RGBA的时候
if (img.shape[2] == 3):
#转换为RGB排列
RGBImg = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
#RGBImg.shape[1]*RGBImg.shape[2]这一句时用来解决扭曲的问题
#详情参考 https://blog.csdn.net/owen7500/article/details/50905659 这篇博客
image = QtGui.QImage(RGBImg, RGBImg.shape[1], RGBImg.shape[0],
RGBImg.shape[1]*RGBImg.shape[2], QtGui.QImage.Format_RGB888)
elif (img.shape[2] == 4):
#读入为RGBA的时候
RGBAImg = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
image = array2qimage(RGBAImg)
return image

解决 OpenCV 读取的图像转换成 QImage 时出现扭曲的现象

最开始我是直接用的

1
2
image = QtGui.QImage(RGBImg, RGBImg.shape[1], RGBImg.shape[0],
QtGui.QImage.Format_RGB888)

这样在对于是四个字节一行的数据的图像的时候没有问题,但对于不是四个字节一行的图片的时候就会出现不能对齐的问题了。
错误的原理是图片数据没有按四个字节对齐,具体可以参考这篇博客

具体表现出来的形式就是图像被左右颠倒,并且大幅度的从左到右的扭曲了。

我的解决方法是参考这篇博客的解决方案。

这样修改后的转换函数就是下面这个样子的。我这里多了一个参数RGBImg.shape[1]*RGBImg.shape[2],这个就是用来指明一行有多少个字节的。

1
2
image = QtGui.QImage(RGBImg, RGBImg.shape[1], RGBImg.shape[0],
RGBImg.shape[1]*RGBImg.shape[2], QtGui.QImage.Format_RGB888)

解决 OpenCV 读取的灰度图像转换成 QImage 时出现变成黑白条纹的现象

其实这个不算是解决了,只是调用了别人的库,不过我看了别人的源码也没看懂这个到底是怎么做到转换的。大概的实现方式就是下面这样,调用别人的包就完事了。

1
2
3
if (len(img.shape)==2):
# 读入灰度图的时候
image = array2qimage(img)

保存图像

具体的实现方式是通过调用QFileDialog.getSaveFileName()来获取要保存的文件的类型和地址,然后利用OpenCV的imwrite()来实现保存图片的功能。

保存方面参考的这篇博客

1
2
3
4
5
6
# 利用OpenCV保存图片
# 按不同的格式区分,分别对应不同的参数
if imgType == "*.jpg":
cv2.imwrite(imgName, self.destImg, [cv2.IMWRITE_JPEG_QUALITY, 50])
elif imgType == "*.png":
cv2.imwrite(imgName, self.destImg, [cv2.IMWRITE_PNG_COMPRESSION, 0])

说完了业务逻辑的设计,接下来就是我的各种算法的实现啦。

边缘检测

其实这几种都大同小异,都是利用模板然后进行卷积。为了方便看可以把结果二值化处理

利用 sobel 算子进行边缘检测

简单地说就是定义x,y两个方向的sobel算子,然后利用两个算子分别对原图像卷积,最后对两个方向上的结果进行求和。

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
def sobel(img):
"""
利用 sobel 算子 进行边缘检测
读入 OpenCV格式的BGR图像,返回OpenCV格式的灰度图像
"""

# 定义sobel算子
sobel_x = [[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]]
sobel_y = [[-1, -2, 1],
[0, 0, 0],
[1, 2, -1]]
# 定义阈值
valve = 188
# 转换为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 获取长宽
row, col = img_gray.shape
result = np.zeros((row, col))
for x in range(1, row-1):
for y in range(1, col-1):
# 不管四个边进行边缘检测
sub = img_gray[x-1:x+2, y-1:y+2]
var_x = np.sum(np.matmul(sub, sobel_x))
var_y = np.sum(np.matmul(sub, sobel_y))
var = abs(var_x) + abs(var_y)
if(var > valve):
var = 0
else:
var = 255
result[x, y] = var
return result

利用 prewitt 算子进行边缘检测

和 sobel 算子大同小异,定义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
28
29
30
31
32
33
34
35
def prewitt(img):
"""
利用 prewitt 算子进行边缘检测
读入 OpenCV格式的BGR图像,返回OpenCV格式的灰度图像
"""
# 定义prewitt算子
prewittx = [[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]]
prewitty = [[1, 1, 1],
[0, 0, 0],
[-1, -1, -1]]
prewittx = np.array(prewittx)
prewitty = np.array(prewitty)
# 定义阈值
valve = 188
# 转换为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 获取长宽
row, col = img_gray.shape
result = np.zeros((row, col))
for x in range(1, row-1):
for y in range(1, col-1):
# 不管四个边进行边缘检测
sub = img_gray[x-1:x+2, y-1:y+2]
var_x = np.sum(np.matmul(sub, prewittx))
var_y = np.sum(np.matmul(sub, prewitty))
var = sqrt(var_x*var_x+var_y*var_y)
if(var > valve):
var = 0
else:
var = 255
result[x, y] = var

return result

利用 laplace 算子进行边缘检测

这个有点不一样的就是只有一个算子,但这个算子兼顾了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
28
29
def laplace(img):
"""
利用拉普拉斯算子进行边缘检测
读入 OpenCV格式的BGR图像,返回OpenCV格式的灰度图像
"""
# 定义 laplace 算子
laplaceop = [[0, 1, 0],
[1, -4, 1],
[0, 1, 0]]
laplaceop = np.array(laplaceop)
# 定义阈值
valve = 81
# 转换为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 获取长宽
row, col = img_gray.shape
result = np.zeros((row, col))
for x in range(1, row-1):
for y in range(1, col-1):
# 不管四个边进行边缘检测
sub = img_gray[x-1:x+2, y-1:y+2]
var = np.sum(np.matmul(sub, laplaceop))
if(var > valve):
var = 0
else:
var = 255
result[x, y] = var
return result

阈值分割

我试了下,基本就是找到一个大概的阈值。然后通过这个阈值我就可以将图像从原图中分离出来。可以是背景也可以前景主体。

利用 迭代阈值法 进行阈值分割

迭代阈值法主要思想就是

  1. 先假定一个阈值
  2. 然后不停的计算低于阈值的像素的平均值T1和高于阈值的像素的平均值T2。
  3. 最后将这两个平均值求平均T=(T1+T2)/2
  4. 把这个T和当前的阈值相比较,如果没变动,或者进入一个循环了就退出迭代。如果有变动就到步骤2继续循环

我这里为了判断退出循环的条件引入了一个变量计算每次的阈值变化的差值。如果上次变化的差值和当前变化的差值相同,或者更小,那我就退出循环。

因为随着迭代,每一步的变动必然是也来越小的,如果出现了差值反而变大的情况,那就直接退出。

下面是我的代码实现

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def genrate(img):
"""
迭代阈值法
输入OpenCV格式的BGR图,输出OpenCV格式的灰度图
"""
# 转换为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 获取长宽
row, col = img_gray.shape
result = np.zeros((row, col))
# 求第一代阈值
value = int((int(img_gray.max()) + int(img_gray.min()))/2)
# 生成直方图便于计算
F = np.zeros(256)
for x in range(row) :
for y in range(col) :
F[img_gray[x][y]] = F[img_gray[x][y]] + 1
# 获得前景色的数量
def getFrontColorNum(median):
nFrontColor = 0
for i in range (median,256):
nFrontColor = nFrontColor + F[i]
return nFrontColor

# 获得背景色的数量
def getBackColorNum(median):
nBackColor = 0
for i in range(median):
nBackColor = nBackColor + F[i]
return nBackColor

# 计算下一代阈值
def getNextValue(median):
tmp1 = 0
tmp2 = 0
sum1 = 0
sum2 = 0
for i in range(median,256):
tmp1 = tmp1 + F[i] * i
sum1 = tmp1/getFrontColorNum(median)
for i in range(median):
tmp2 = tmp2 + F[i] * i
sum2 = tmp2/getBackColorNum(median)
return (sum1+sum2)/2

nextValue = int(getNextValue(value))
difference = abs(nextValue - value)
# 迭代阈值
while (nextValue!=value):
value = nextValue
nextValue = int(getNextValue(value))
# 当差值不再减小时说明就找到了合适的阈值
if difference <= abs(nextValue - value):
break
value = int(value)
print("迭代阈值法的结果为",value)
# 二值化
for x in range(row):
for y in range(col):
if value > img_gray[x][y]:
result[x][y] = 0
else :
result[x][y] = 255
return result

利用 LOG 算子 进行阈值分割

其实我觉得这个应该算是边缘检测的,但是老师把这个归入了阈值分割那我就跟着她做吧。

简单地说就是定义一个5x5的模板,然后和原图卷积。其实最好还要求零交叉,不过我还没搞定这个,等搞定再说。

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
def log(img):
"""
log算法 阈值分割
输入OpenCV格式的BGR图片,输出OpenCV格式的灰度图
threshold
"""
# 定义 LOG 算子
logop = [[-2,-4,-4,-4,-2],
[-4,0,8,0,-4],
[-4,8,24,8,-4],
[-4,0,8,0,-4],
[-2,-4,-4,-4,-2]]
logop =np.array(logop)
# 定义阈值
valve = 368
# 转换为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 获取长宽
row, col = img_gray.shape
result = np.zeros((row, col))
for x in range(2, row-2):
for y in range(2, col-2):
# 不管四个边进行边缘检测
sub = img_gray[x-2:x+3, y-2:y+3]
var = np.sum(np.matmul(sub, logop))
if(var > valve):
var = 0
else:
var = 255
result[x, y] = var
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def maximus(img):
"""
一维最大熵
输入OpenCV格式的BGR图片,输出OpenCV格式的灰度图
Threshold 阈值
参考 https://blog.csdn.net/Robin__Chou/article/details/53931442
"""
# 转换为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 获取长宽
row, col = img_gray.shape
# 生成结果图像矩阵
result = np.zeros((row, col))
# 生成直方图便于计算
F = np.zeros(256)
for x in range(row) :
for y in range(col) :
F[img_gray[x][y]] = F[img_gray[x][y]] + 1


def getFrontColorNum(median):
"""获得前景色的数量,输入 median 为阈值的大小"""
nFrontColor = 0
for i in range (median,256):
nFrontColor = nFrontColor + F[i]
return nFrontColor

def getBackColorNum(median):
"""获得背景色的数量,输入 median 为阈值的大小"""
nBackColor = 0
for i in range(median):
nBackColor = nBackColor + F[i]
return nBackColor

maxEntropy = -10
threshold = 0
# 求出最大熵
for tmpThreshold in range(256) :
nFrontColor = getFrontColorNum(tmpThreshold)
nBackColor = getBackColorNum(tmpThreshold)
# 计算背景熵
backEntropy = 0
for i in range(tmpThreshold):
if F[i]!=0 :
Property = F[i]/nBackColor
backEntropy = -Property*log10(float(Property)) + backEntropy
# 计算前景熵
frontEntropy = 0
for i in range(tmpThreshold,256):
if F[i] != 0 :
Property = F[i]/nFrontColor
frontEntropy = -Property*log10(float(Property)) + frontEntropy
# 求最大熵
if (frontEntropy + backEntropy >= maxEntropy) :
maxEntropy = frontEntropy + backEntropy
threshold = tmpThreshold
print("一维最大熵的阈值为:",threshold)
# 二值化结果
for x in range(row):
for y in range(col):
if threshold > img_gray[x][y]:
result[x][y] = 0
else :
result[x][y] = 255
return result

参考

pyqt5 从本地选择图片 并显示在label上
QT官方文档:QGraphicsView
How to display a Mat image in Qt
Python PyQt5.QtWidgets.QGraphicsScene() Examples
解决QLabel显示图片扭曲的问题
Opencv 随笔1------ 图片的读写(png jpg)
opencv 报错’depth’ is 6 (CV_64F)全因numpy 默认float类型是float64位
OpenCV - 最大熵分割
PyQT5速成教程-2 Qt Designer介绍与入门


数字图像处理大作业记录-边缘检测与阈值分割的Python实现及利用PyQt5的GUI开发
https://www.yikakia.com/数字图像处理大作业记录/
作者
Yika
发布于
2020年5月28日
许可协议