OpenCV Python开发 第二章 深度估计与分割

OpenCV Python开发 第二章 深度估计与分割

  • 章节简介
  • 同专栏博客
  • 深度图像
    • 深度相关(depth-related)通道
    • 自定义模块类
    • 面向对象的Cameo
      • 使用managers.CaptureManager提取视频流
      • 使用managers.WindowManager抽象窗口和键盘
      • cameo.Cameo的强大实现
    • 从视差图得到掩模
    • 对复制操作执行掩模
    • 使用普通摄像头进行深度估计
  • GrabCut前景检测
  • 使用分水岭算法进行图像分割
  • 章节总结
    • 章节API
    • 章节modules类和全部代码

前面第一章我们介绍了图像处理的基础, 这一章我们来学习和图像深度以及背景分割有关的内容.

章节简介

本章介绍怎样使用深度摄像头的数据来识别前景区域和背景区域, 这样就可以分别对前景和背景做不同的处理. 首先需要一个深度摄像头…比如微软的Kinect, 然后需要对OpenCV-Python源码进行一些重构, 使其支持深度摄像头.

本章重点在于使用两种不同的方法来探索深度估计. 第一种是通过使用深度摄像头; 第二种是使用立体图像来进行深度估计, 这只需要普通摄像头就行了.

由于条件比较苛刻(一个kinect单价大概需要2000人民币…), 加上手头的kinect最近有点问题…涉及深度的代码并不给出运行结果

同专栏博客

PythonOpencv开发 Python3.6.7+Opencv3.4.2.16环境配置

PythonOpenCV开发 前言

OpenCV Python开发 第一章 图像处理基础

OpenCV Python开发 第一章课后 自定义实现API

深度图像

这一小节可能有点小硬核, 身边没有深度摄像头的同学可以跳过这一小节.

一个计算机可能有多个捕获视频的设备, 每个设备又可能有多个通道. 比如一个立体摄像头设备, 每个通道都可能对应有不用的透镜和传感器, 而且每个通道可能有不同类型的数据, 比如一个是RGB彩色图, 另一个是RGB-D深度图.

深度相关(depth-related)通道

  • 深度图: 一种灰度图像, 该图像的每个像素值都是摄像头到物体表面之间距离的估计值, 比如, CAP_OPENNI_EDPTH_MAP通道的图像给出了基于浮点数的距离, 该距离以毫米为单位.
  • 点云图: 一种彩色图像, 该图像的每种颜色都对应一个(x或y或z)维度空间. CAP_OPENNI_POINT_CLOUD_MAP通道会得到BGR图像, 从摄像头的角度来看, B对应x(蓝色向右), G对应y(绿色向上), R对应z(红色对应深度), 这个值的单位是米.
  • 立体视差: 假如从不同视角观察同一场景, 可能会让人认为是两张图像(比如三棱锥的正视图是一个三角形, 俯视图是一个圆). 针对两张图象中的同一个物体之间, 任意一对对应的像素点, 可以度量这些像素点之间的距离. 这个度量值就是立体视差. 显而易见, 近的物体会产生很大的立体视差, 远的物体视差会减少.
  • 视差图: 一种灰度图像, 该图像的每个像素代表物体表面的立体视差. 距离由近到远, 相应的像素值会从亮变暗
  • 有效深度掩模: 表明一个给定的像素的深度信息是否有效(非零值表示有效, 零值表示无效). 比如, 如果深度摄像头依赖与红外照明器, 在灯光被遮挡的区域的深度信息就为无效.

自定义模块类

我们在项目目录下创建modules文件夹, 在里面创建cameo.py, depth.py, filters.py, managers.py, rects.py, trackers.py 以及utils.py这么7个文件.

OpenCV Python开发 第二章 深度估计与分割_第1张图片

各个文件的代码我就不在这里打出来了, 不然实在太占篇幅, 所有文件已上传至个人资源, 无需积分即可下载.

面向对象的Cameo

OpenCV I/O流接收的所有图像都是相似的, 尽管图像的来源或去向不同, 都可以将相同逻辑应用到图像流中的每个帧. 在应用中, 将I/O代码与应用程序代码分离会变得特变方便.

我们创建CaptureManager类和WindowManager类作为高级的I/O流接口. 在应用程序的代码中可以使用CaptureManager来读取新的帧, 并能将帧分派到一个或多个输出中, 这些输出包括静止的图像文件, 视频文件以及窗口. WindowManager类使应用程序代码能够以面向对象的形式处理窗口和事件

使用managers.CaptureManager提取视频流

无论该图像流来自视频文件还是摄像头, OpenCV都可以获取,显示并记录图像流, 但是每种情况都有一些需要特殊考虑的地方. CaptureManager类对一些差异进行了抽象, 并提供了更高级的接口从获取流中分配图像, 在将图像分到一个或多个输出中.

class CaptureManager(object):

    def __init__(self, capture, previewWindowManager=None, shouldMirrorPreview=False):
		pass

    @property
    def channel(self):
        pass

    @channel.setter
    def channel(self, value):
        pass

    @property
    def frame(self):
        pass

    @property
    def isWritingImage(self):
        pass

    @property
    def isWritingVideo(self):
        pass

    def enterFrame(self):
        pass

    def exitFrame(self):
        pass

    def writeImage(self, filename):
        pass

    def startWritingVideo(self, filename, encoding=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')):
        pass

    def stopWritingVideo(self):
        pass

    def _writeVideoFrame(self):
        pass

如果应用程序代码处理了帧属性, 那么在记录文件和窗口中会有所体现. CaptureManager类有一个shouldMirrorPreview的构造参数和属性, 如果想要帧在窗口中镜像翻转, 但不记录在文件中, 可将其置为True. 通常, 当面对着摄像头时, 用户会更习惯返回镜像后的图像.

注意这个CaptureManager类的大多数成员变量和函数都是非公成员变量和函数(以_开头), 这对程序而言没有任何区别, 这么写的目的是面向用户的. 由于Python不像java, c++有public和protected修饰符, 任何python对象都可以访问其任何成员变量和函数, 因此我们约定:

python对象中, 凡是以单下划线_开头的成员变量和成员函数, 称为保护变量, 即只有类对象和子类对象能访问这些变量. 而以双下划线__开头的变量称为私有成员变量, 即只有类对象自己能访问, 子类对象不可访问

所以这个CaptureManager类对外能够调用的成员变量只有previewWindowManager和shouldMirrorPreview两个.

置于装饰器@property的作用, 详细的解释有点占篇幅, 不明白的同学可以参考这里, 不明白也没关系, 这只是非公成员变量的编写习惯, 记住就行

def __init__(self, capture, previewWindowManager=None, shouldMirrorPreview=False):
	# public member variables
    self.previewWindowManager = previewWindowManager
	self.shouldMirrorPreview = shouldMirrorPreview
	
    # private member variables
    self._capture = capture
    self._channel = 0
    self._enteredFrame = False
    self._frame = None
    self._imageFilename = None
    self._videoFilename = None
    self._videoEncoding = None
    self._videoWriter = None
    self._startTime = None
    self._framesElapsed = 0
    self._fpsEstimate = None

在应用程序主循环的每一次迭代中, 通常应调用CaptureManager类的enterFrame()和exitFrame()函数. 在调用两者之间, 应用程序可能会设定通道属性并获取帧属性. 通道属性的初始值时0, 只有在多头摄像头(multihead camera)的情况下, 通道属性的初始值非0. 帧属性时当调用enterFrame()函数时与当前通道状态对应的图像

def enterFrame(self):
    """Capture the next frame, if any."""

    # But first, check that any previous frame was exited.
    assert not self._enteredFrame, 'previous enterFrame() had no matching exitFrame()'

    if self._capture is not None:
    	self._enteredFrame = self._capture.grab()
	return

def exitFrame(self):
    """Draw to the window. Write to files. Release the frame."""

    # Check whether any grabbed frame is retrievable.
    # The getter may retrieve and cache the frame.
    if self.frame is None:
        self._enteredFrame = False
        return

    # Update the FPS estimate and related variables.
    if self._framesElapsed == 0:
        self._startTime = time.time()
    else:
        timeElapsed = time.time() - self._startTime
        self._fpsEstimate = self._framesElapsed / timeElapsed
    self._framesElapsed += 1

    # Draw to the window, if any.
    if self.previewWindowManager is not None:
        if self.shouldMirrorPreview:
            mirroredFrame = numpy.fliplr(self._frame).copy()
            self.previewWindowManager.show(mirroredFrame)
        else:
            self.previewWindowManager.show(self._frame)

    # Write to the image file, if any.
    if self.isWritingImage:
        cv2.imwrite(self._imageFilename, self._frame)
        self._imageFilename = None

    # Write to the video file, if any.
    self._writeVideoFrame()

    # Release the frame.
    self._frame = None
    self._enteredFrame = False    

此外也可能会经常性地调用CaptureManager类地writeImage(), startWritingVideo()以及stopWritingVideo()函数. 在调用exitFrame()函数之前, 会延迟写入文件. 并且, 在调用exitFrame()函数的过程中, 帧属性可能会在窗口中显示, 这取决于应用程序代码是将WindowManager类作为CaptureManager的构造函数参数, 还是设置previewWindowManager属性.

def writeImage(self, filename):
    """Write the next exited frame to an image file."""
    self._imageFilename = filename

def startWritingVideo(self, filename, encoding=cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')):
    """Start writing exited frames to a video file."""
    self._videoFilename = filename
    self._videoEncoding = encoding

def stopWritingVideo(self):
    """Stop writing exited frames to a video file."""
    self._videoFilename = None
    self._videoEncoding = None
    self._videoWriter = None

使用managers.WindowManager抽象窗口和键盘

OpenCV提供了创建和销毁窗口, 显示图像和处理事件的函数, 这些函数不是窗口类的方法, 只需要窗口名作为参数. 由于这个接口不是面向对象的, 所以与OpenCV通常的风格不一致, 也不太可能兼容到其他窗口或事件的处理接口

基于面向对象和适应性兼容性的缘故, 我们自定义createWindow(), destroyWindow(), show()和processEvents()函数, 将其添加到WindowManager类中, 该类有一个函数keypressCallback(), 会被processEvents()调用, 用来处理任意按键. keypressCallback()必须带一个参数, 比如ASCII码

class WindowManager(object):

    def __init__(self, windowName, keypressCallback=None):
        self.keypressCallback = keypressCallback

        self._windowName = windowName
        self._isWindowCreated = False

    @property
    def isWindowCreated(self):
        return self._isWindowCreated

    def createWindow(self):
        cv2.namedWindow(self._windowName)
        self._isWindowCreated = True

    def show(self, frame):
        cv2.imshow(self._windowName, frame)

    def destroyWindow(self):
        cv2.destroyWindow(self._windowName)
        self._isWindowCreated = False

    def processEvents(self):
        keycode = cv2.waitKey(1)
        if self.keypressCallback is not None and keycode != -1:
            # Discard any non-ASCII info encoded by GTK.
            keycode &= 0xFF
            self.keypressCallback(keycode)

当前我们实现的仅仅支持键盘事件, 不过这对于cameo来说已经足够了.

cameo.Cameo的强大实现

Cameo类提供两种方法启动应用程序, run()和onkeypress(). 初始化时, Cameo类会把onkeypress()作为回调函数, 创建WindowManager类, 而CaptureManager类会使用摄像头和WindowManager类. 当调用run()函数时, 应用程序会执行主循环处理帧和事件, 应用程序会调用onkeypress()函数处理事件. 按空格可以获取截图信息, 按tab可以启动/停止截屏, 按Esc可推出应用程序

被注释掉的代码我们会在之后的章节中介绍

class Cameo(object):

    def __init__(self):
        self._windowManager = WindowManager('Cameo', self.onKeypress)
        self._captureManager = CaptureManager(cv2.VideoCapture(0), self._windowManager, True)

        # self._faceTracker = FaceTracker()
        # self._shouldDrawDebugRects = False
        # self._curveFilter = filters.BGRPortraCurveFilter()

    def run(self):
        """Run the main loop."""
        self._windowManager.createWindow()
        while self._windowManager.isWindowCreated:
            self._captureManager.enterFrame()
            frame = self._captureManager.frame

            # if frame is not None:
            #
            #     self._faceTracker.update(frame)
            #     faces = self._faceTracker.faces
            #     rects.swapRects(frame, frame, [face.faceRect for face in faces])
            #
            #     filters.strokeEdges(frame, frame)
            #     self._curveFilter.apply(frame, frame)
            #
            #     if self._shouldDrawDebugRects:
            #         self._faceTracker.drawDebugRects(frame)

            self._captureManager.exitFrame()
            self._windowManager.processEvents()

    def onKeypress(self, keycode):
        """Handle a keypress.

        space  -> Take a screenshot.
        tab    -> Start/stop recording a screencast.
        x      -> Start/stop drawing debug rectangles around faces.
        escape -> Quit.

        """
        if keycode == 32:  # space
            self._captureManager.writeImage('screenshot.png')
        elif keycode == 9:  # tab
            if not self._captureManager.isWritingVideo:
                self._captureManager.startWritingVideo('screencast.avi')
            else:
                self._captureManager.stopWritingVideo()
        elif keycode == 120:  # x
            self._shouldDrawDebugRects = not self._shouldDrawDebugRects
        elif keycode == 27:  # escape
            self._windowManager.destroyWindow()
            
if __name__ == '__main__':
    Cameo().run()

运行这个程序, 我们可以就可以通过键盘来控制摄像头了

从视差图得到掩模

cameo可以精确地估计面部区域, 使用FaceTracker函数和一幅普通的彩色图像就可以得到面部区域的矩形估计. 通过相应的视差图来分析这个矩形区域, 就可以断定矩形区域里的某些像素是否为噪声, 即距离太近还是距离太远, 从而影响结果. 可以通过去除这些噪声来精炼面部区域

在depth.py中有如下一个函数

def createMedianMask(disparityMap, validDepthMask, rect=None):
    """
    Return a mask selecting the median layer, plus shadows.
    """
    if rect is not None:
        x, y, w, h = rect
        disparityMap = disparityMap[y:y+h, x:x+w]
        validDepthMask = validDepthMask[y:y+h, x:x+w]
    median = numpy.median(disparityMap)
    return numpy.where((0==validDepthMask) | (abs(disparityMap-median)<12), 1.0, 0.0)

该函数生成一个掩模, 使得面部矩形中不想要的区域的掩模值为0, 想要的区域的掩模值为1. 这个函数将视差图, 有效深度掩膜和一个矩形作为参数.

为了识别出视差图中的噪声, 首先需要用numpy.median()来得到中位值

median(a, axis=None, out=None, overwrite_input=False, keepdims=False)

该函数的可选参数有4个, 必须参数为一个数组 a a a. 当数组元素排序完毕时, 若数组的长度是奇数, 则返回中间位置的值, 如果是偶数, 那就返回中间两个数的平均值.

生成掩模需要逐像素地进行布尔操作, 可以使用numpy.where()函数

where(condition, x=None, y=None)

该函数有三个参数, 第一个参数condition为数组或列表, 该数组地元素为布尔值. 函数会返回相同维度的数组. condition的元素为真时, 返回数组的相应元素为函数的第二个参数x的相应元素, 否则就是y的相应元素

对复制操作执行掩模

rects.py中的copyRect函数可以将源图像中的指定矩形区域复制到目标图像中, 而且只复制指定矩形中, 掩模值为非零值的像素, 过滤其他像素.

def copyRect(src, dst, srcRect, dstRect, mask=None, interpolation=cv2.INTER_LINEAR):
    """Copy part of the source to part of the destination."""

    x0, y0, w0, h0 = srcRect
    x1, y1, w1, h1 = dstRect

    # Resize the contents of the source sub-rectangle.
    # Put the result in the destination sub-rectangle.
    if mask is None:
        dst[y1:y1 + h1, x1:x1 + w1] = cv2.resize(src[y0:y0 + h0, x0:x0 + w0], (w1, h1),
                       						    interpolation=interpolation)
    else:
        if not utils.isGray(src):
            # Convert the mask to 3 channels, like the image.
            mask = mask.repeat(3).reshape(h0, w0, 3)
        # Perform the copy, with the mask applied.
        dst[y1:y1 + h1, x1:x1 + w1] = \
            np.where(cv2.resize(mask, (w1, h1), interpolation=cv2.INTER_NEAREST),
                     cv2.resize(src[y0:y0 + h0, x0:x0 + w0], (w1, h1), interpolation=interpolation),
                     dst[y1:y1 + h1, x1:x1 + w1])

使用普通摄像头进行深度估计

深度摄像头能够在捕获图像的同时估计物体与摄像头之间的距离, 属于不太常见的设备. 比如微软的Kinect, 它结合了传统摄像头和一个红外传感器来帮助摄像头区别相似物体并估计距离.

通常来说, 我们身边的手机、电脑都配备了一个普通的摄像头, 那么我们应该如何利用这些摄像头来模拟深度摄像头呢?

这里会用到几何学中的极几何(Epipolar Geometry), 它属于立体视觉几何学. 立体视觉是计算机视觉的一个分支, 他从同一物体的两张不同角度的图像来提取三维信息.

从概念上讲, 极几何跟踪从摄像头到图像上每个物体的虚线, 然后再第二张图像上做相同的操作, 并根据同一个物体对应的线交叉来计算距离. 下图为一个简易的示意图.

OpenCV Python开发 第二章 深度估计与分割_第2张图片

下面介绍OpenCV如何使用极几何来计算所谓的视差图, 它是对图像中检测到的不同深度的基本表示. 这样就能够提取出一张图片的前景部分而抛弃其余部分.

这里我们需要同一物体在两个不同视角下的两幅图像, 并且注意这两幅图像中, 物体与摄像头之间的距离应该尽可能相等. 否则视差图也就变得没有意义. 并且这两张图像的长宽必须一致

def depthTest(imgL, imgR):
    stereo = cv.StereoSGBM_create(numDisparities=16, blockSize=15)
    disparity = stereo.compute(imgL, imgR)
    imgs = [imgL, imgR, disparity]
    titles = ['左视角', '右视角', '视差图']
    for i in range(3):
        plt.subplot(2, 2, i+1)
        plt.imshow(imgs[i], cmap='gray')
        plt.title(titles[i])
        plt.xticks([]), plt.yticks([])
    plt.show()

运行结果如下

OpenCV Python开发 第二章 深度估计与分割_第3张图片

处理的过程非常简单: 加载两幅灰度图像, 创建一个StereoSGBM实例, (semiglobal block matching的缩写, 这是一种计算视差图的方法), 然后计算出结果. 需要注意的是两幅图像必须是相同的长宽

这里给出StereoSGBM_Create()用到的几个参数

参数 描述
minDisp 可能的最小视差值. 通常为0, 但有时候校正算法会移动图像, 所以参数值也要相应调整
numDisp 最大的视差与最小的视差之差. 这个差值总是大于0.
windowSize 一个匹配块的大小, 必须是大于等于1的奇数, 通常在3~11之间
P1 控制视差图平滑度的第一个参数
P2 控制视差图平滑度的第二个参数. 这个值越大, 视差图月平滑. P1是临近像素间视差值变化为1时的惩罚值, P2是临近像素间视差值变化大于1的惩罚值. 算法要求P2>P1
disp12MaxDiff 在左右视差检查中, 允许的最大偏差, 以像素为单位, 这个值小于等于0时将不做任何检查
preFilterCap 预过滤图像像素的截断值. 算法首先计算每个像素在x方向上的衍生值, 然后使用在区间 [ − p r e F i l t e r C a p , p r e F i l t e r C a p ] [-preFilterCap, preFilterCap] [preFilterCap,preFilterCap]上来截取它的值. 最后的结果传给像素代价函数
uniquenessRatio 只有当由代价函数计算得到的最好结果值, 与第二好的值, 之间的误差(用百分比表示)小于这个值时, 才被认为是正确的. 通常在5~15
speckleWinowSize 平滑视差区域的最大窗口尺寸, 以考虑噪声斑点或无效性. 将它设为0就不会进行斑点过滤. 一般取50~200之间的某个值
speckleRange 每个已连接部分的最大视差变化. 如果进行斑点过滤, 则该参数取正值, 函数会自动乘以16. 一般情况下取1或者2就足够了

GrabCut前景检测

计算视差图对检测图像的前景很有用, 但上述的StereoSGBM不是唯一能够完成这个功能的算法, StereoSGBM主要从二维图像中得到三维信息. 而下面我们介绍GrabCut算法, 该算法很好地实现了前景检测

GrabCut算法的实现步骤为:

  1. 在图像中定义含有一个或多个物体的矩形
  2. 矩形外的区域被自定认为是背景
  3. 对于用户定义的矩形区域, 用背景中的数据区区别它里面的前景和背景区域
  4. 用高斯混合模型(Gaussian Mixture Model, GMM)来对背景和前景建模, 并将未定义的像素标记为可能的前景或背景
  5. 图像中的每一个像素都被看做通过虚拟边与周围像素相连接, 而每条边都有一个属于前景或背景的概率, 这基于它与周围像素颜色上的相似性.
  6. 每一个像素点会与一个前景或背景节点连接
  7. 在结点完成链接后, 若一个结点属于前景, 另一个属于背景, 则切断它们之间的边, 这就能将图像各部分分割出来. 像下图所示

OpenCV Python开发 第二章 深度估计与分割_第4张图片

我们来看一个例子, 由于部分注释比较长, 这里我逐步给出解释和代码

我们定义一个函数grabCut(img), 接受一个BGR图img作为参数, 然后我们需要创建一个与img形状相同的掩模, 并用0做填充处理, 同时我们深拷贝一个img的副本img_orig.

def grabCut(img):
    img_orig = deepcopy(img)
    mask0 = np.zeros(img.shape[:2], np.uint8)    # 创建相同形状的掩模, 并用零填充

接着我们需要创建前景和背景的模型, 同样做0填充处理

    bgdModel = np.zeros((1, 65), np.float64)    # 背景模型
    fgdModel = np.zeros((1, 65), np.float64)    # 前景模型

这里我们其实可以用非0来填充这几个模型, 但是我准备用一个标识矩形来初始化GrabCut()算法(另一个初始化方法是使用非零的掩模mask). 所以前景和背景模型都要基于这个矩形的区域来决定

需要注意的是, 这个矩形的参数很大程度上影响了分割结果, 这个矩形的四个参数(x, y, w, h)需要尽可能地将前景完整地包含在里面, 在这矩形之外的部分, 一律会被视为背景.

我这里选取了一幅811*542的图像, 是一座房子, 房子整体在图像正中央, 因此我定义矩形如下

rect = (85, 80, 600, 500)	# 4个参数需要视具体情况而定

然后我们初始化grabCut()算法, 迭代次数取5

cv.grabCut(img, mask0, rect, bgdModel, fgdModel, 5, cv.cv.GC_INIT_WITH_RECT)

到这里, 我们的掩模mask0已经变成了0~3之间的数值. 值为0和2的将转变为0, 为1和3的将转为1, 然后保存到另一个掩模mask1中. 这样就可以用mask1过滤出所有的0值像素, 并保留绝大部分的前景像素

mask1 = np.where((2==mask0)|(0==mask0), 0, 1).astype('uint8')
img = img*mask2[:, :, np.newaxis]

最后我们将原图和grabCut的前景, 背景图展示出来, 运行结果如下

OpenCV Python开发 第二章 深度估计与分割_第5张图片

这一小节的完整代码如下

def grabCut(img):
    img_orig = deepcopy(img)
    mask0 = np.zeros(img.shape[:2], np.uint8)    # 创建相同形状的掩模, 并用零填充

    # 创建以0填充的背景和前景模型
    bgdModel = np.zeros((1, 65), np.float64)    # 背景模型
    fgdModel = np.zeros((1, 65), np.float64)    # 前景模型
    rect = (85, 80, 600, 500)
    cv.grabCut(deepcopy(img), mask0, rect, bgdModel, fgdModel, 5, cv.GC_INIT_WITH_RECT)
    mask1 = np.where((2 == mask0) | (0 == mask0), 0, 1).astype('uint8')
    img_fgd = img * mask1[:, :, np.newaxis]
    img_bgd = img_orig-img_fgd

    imgs = [img_orig, img_fgd, img_bgd]
    ttls = ['原图', '前景', '背景']
    for i in range(3):
        plt.subplot(2, 2, i+1)
        plt.imshow(cv.cvtColor(imgs[i], cv.COLOR_BGR2RGB))
        plt.title(ttls[i])
        plt.xticks([]), plt.yticks([])
    plt.show()

使用分水岭算法进行图像分割

本章最后我们来介绍一下分水岭算法, 之所以叫做分水岭(watershed)是因为它里面有一个叫做"水"的概念.

我们把图像中低密度的区域(即与周围像素的差异不大, 梯度变化小)想象成山谷, 高密度的区域想象成山峰. 我们向山谷中持续注水, 直到不同的山谷中的水开始汇聚. 为了阻止这种汇聚, 我们设置一些栅栏, 最后得到的栅栏就是图像的分割. 下图是一个简单的原理图

OpenCV Python开发 第二章 深度估计与分割_第6张图片

下面我们来看一个具体的例子, 还是以上一节的房子图像为例.

这里我们需要先将图像转换为灰度图, 并保存副本

def waterShed(img):
	img_gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)

	img_orig = deepcopy(img)

接着我们需要给定一个阈值, 这个操作可将图像分成两个部分, 黑色部分和白色部分

	ret, thresh = cv.threshold(img_gray, 0, 255, cv.THRESH_BINARY_INV + cv.THRESH_OTSU)

下面通过morphologyEx变换来去除噪声, 这是一种通过对图像进行膨胀处理后再进行腐蚀的操作, 可以提取图像特征

    kernel = np.ones((3, 3), np.uint8)
    opening = cv.morphologyEx(thresh, cv.MORPH_OPEN, kernel, iterations=2)

通过对经过morphologyEx处理的opening图像进行膨胀操作, 可以得到大部分都是背景的区域

    sure_bg = cv.dilate(opening, kernel, iterations=3)

反之, 可以通过distanceTransform来获取确定的前景区域. 这是图像中最可能是前景的区域, 越是远离背景区域的边界的点越可能属于前景区域.

在得到distanceTransform的结果之后, 需要应用一个阈值来决定哪些区域是前景, 这样得到正确的结果的概率很高

    dist_transform = cv.distanceTransform(opening, 1, 5)
    ret, sure_fg = cv.threshold(dist_transform, 0.01 * dist_transform.max(), 255, 0)

这个阶段之后, 所得到的前景和背景中有重合的部分怎么办呢?

首先我们需要确定这些区域, 可以通过sure_bg和sure_fg的集合相减来得到

    sure_fg = np.uint8(sure_fg)
    unknown = cv.subtract(sure_bg, sure_fg)

有了这些区域, 就可以设置栅栏了来阻止水汇聚了. 这是通过connectedComponents函数来完成的. 给出一些确定的前景区域, 其中一些节点会连接在一起, 而另一些节点并没有连接在一起. 这意味着他们属于不同的山谷, 在他们之间应该有一个栅栏

    ret, markers = cv.connectedComponents(sure_fg)

这里给出connectedComponents的详解, 函数如下

connectedComponents(image, labels=None, connectivity=None, ltype=None)

这个函数能够计算二值图像的连通域标记图像, 参数说明如下

参数 描述
image 用于标记的8位单通道图像
labels 标记的目标图像
connectivity 4连通或8连通
ltype 输出图像的标记类型

在背景区域加上1, 然后将unknown区域设为0

    markers = markers + 1
    markers[unknown == 255] = 0

最后打开门, 让水漫起来并把栅栏绘制成红色

    markers = cv.watershed(img, markers)
    img[markers == -1] = [0, 0, 255]

完整的代码如下

def waterShed(img):
    img_gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)

    img_orig = deepcopy(img)

    ret, thresh = cv.threshold(img_gray, 0, 255, cv.THRESH_BINARY_INV + cv.THRESH_OTSU)

    kernel = np.ones((3, 3), np.uint8)
    opening = cv.morphologyEx(thresh, cv.MORPH_OPEN, kernel, iterations=2)

    sure_bg = cv.dilate(opening, kernel, iterations=3)

    dist_transform = cv.distanceTransform(opening, 1, 5)
    ret, sure_fg = cv.threshold(dist_transform, 0.01 * dist_transform.max(), 255, 0)

    sure_fg = np.uint8(sure_fg)
    unknown = cv.subtract(sure_bg, sure_fg)

    ret, markers = cv.connectedComponents(sure_fg)

    markers = markers + 1
    markers[unknown == 255] = 0

    markers = cv.watershed(img, markers)
    img[markers == -1] = [0, 0, 255]

    imgs = [img_orig, img, img_orig - img]
    ttls = ['原图', '分水岭前景', '分水岭背景']
    for i in range(3):
        plt.subplot(2, 2, i + 1)
        plt.imshow(cv.cvtColor(imgs[i], cv.COLOR_BGR2RGB))
        plt.title(ttls[i])
        plt.xticks([]), plt.yticks([])
    plt.show()

运行结果如下

OpenCV Python开发 第二章 深度估计与分割_第7张图片

章节总结

本章介绍了从二维输入中得到三维信息以及两种流行方法来进行图像分割. 在下一篇博客中我们将自定义实现其中的一部分API

章节API

"""numpy"""

# 矩阵a的中位数
median(a, axis=None, out=None, overwrite_input=False, keepdims=False)

# 对矩阵进行布尔操作
where(condition, x=None, y=None)



"""OpenCV"""

# 计算视差图
StereoSGBM_create(minDisparity=None, numDisparities=None, blockSize=None, P1=None, P2=None, disp12MaxDiff=None, preFilterCap=None, uniquenessRatio=None, speckleWindowSize=None, speckleRange=None, mode=None)

# grabCut前景检测
grabCut(img, mask, rect, bgdModel, fgdModel, iterCount, mode=None)

# 计算二值图像的连通域标记图像
connectedComponents(image, labels=None, connectivity=None, ltype=None)

章节modules类和全部代码

modules文件夹和完整代码已上传至个人资源, 审核通过后即可下载, 无需积分
链接如下
modules文件夹(压缩)
本章代码

你可能感兴趣的:(OpenCV Python开发 第二章 深度估计与分割)