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

本文最后更新于:2020年8月16日 下午

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

前言

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

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

正文

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

安装库

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

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 文件。

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

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

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

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

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

注册响应函数

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

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

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

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

实际写的话大概是这样的

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

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

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 这个包我才搞定了显示灰度图的问题。

我的显示是这个样子的

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

转换 array 数组到 qiamge 类

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

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 时出现扭曲的现象

最开始我是直接用的

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

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

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

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

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

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

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

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

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

保存图像

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

保存方面参考的 这篇博客

# 利用 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 算子,然后利用两个算子分别对原图像卷积,最后对两个方向上的结果进行求和。

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 方向上的算子,然后分别进行卷积,最后求和。

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 方向上的结果。

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 继续循环

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

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

下面是我的代码实现

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 的模板,然后和原图卷积。其实最好还要求零交叉,不过我还没搞定这个,等搞定再说。

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

利用 一维最大熵 进行阈值分割

简单地说就是通过信息熵的公式求出前景熵和背景熵,通过循环来取不同的值来遍历整个颜色的深度,使得前景熵和背景熵之和最大。这就是一维最大熵的原理。

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 介绍与入门