【问题标题】:Python: How to make this color thresholding function more efficientPython:如何使这个颜色阈值函数更有效
【发布时间】:2017-07-21 06:41:30
【问题描述】:

我在 Python 中编写了一个自适应颜色阈值函数(因为 OpenCV 的 cv2.adaptiveThreshold 不符合我的需要),它太慢了。我已经尽可能地提高了效率,但在 1280x720 图像上仍然需要将近 500 毫秒。

我将不胜感激任何可以提高此功能效率的建议!

函数的作用如下:它使用一个像素厚度的十字形作为结构元素。对于图像中的每个像素,它独立计算四个方向上ksize相邻像素的平均值(即左侧同一行,同一列中ksize像素的平均值上面,在右边的同一行,在下面的同一列中)。我以四个平均值结束,每个方向一个。只有当像素比左右平均值或顶部和底部平均值(加上一些常数C)都亮时,像素才符合阈值标准。

我使用numpy.roll() 同时递增地计算所有像素的平均值,但我仍然需要这样做ksize 次。 ksize 通常为 20-50。

这里是代码,相关部分实际上就是for循环内部发生的事情:

def bilateral_adaptive_threshold(img, ksize=20, C=0, mode='floor', true_value=255, false_value=0):

    mask = np.full(img.shape, false_value, dtype=np.int16)

    left_thresh = np.zeros_like(img, dtype=np.float32) #Store the right-side average of each pixel here
    right_thresh = np.zeros_like(img, dtype=np.float32) #Store the left-side average of each pixel here
    up_thresh = np.zeros_like(img, dtype=np.float32) #Store the top-side average of each pixel here
    down_thresh = np.zeros_like(img, dtype=np.float32) #Store the bottom-side average of each pixel here

    for i in range(1, ksize+1): 
        roll_left = np.roll(img, -i, axis=1)
        roll_right = np.roll(img, i, axis=1)
        roll_up = np.roll(img, -i, axis=0)
        roll_down = np.roll(img, i, axis=0)

        roll_left[:,-i:] = 0
        roll_right[:,:i] = 0
        roll_up[-i:,:] = 0
        roll_down[:i,:] = 0

        left_thresh += roll_right
        right_thresh += roll_left
        up_thresh += roll_down
        down_thresh += roll_up

    left_thresh /= ksize
    right_thresh /= ksize
    up_thresh /= ksize
    down_thresh /= ksize

    if mode == 'floor':
        mask[((img > left_thresh+C) & (img > right_thresh+C)) | ((img > up_thresh+C) & (img > down_thresh+C))] = true_value
    elif mode == 'ceil':
        mask[((img < left_thresh-C) & (img < right_thresh-C)) | ((img < up_thresh-C) & (img < down_thresh-C))] = true_value
    else: raise ValueError("Unexpected mode value. Expected value is 'floor' or 'ceil'.")

    return mask

【问题讨论】:

  • 考虑在Code Review上试试这个
  • 首先,用Python写代码永远无法提高效率。如果可以用 C++ 编写你的逻辑,相信我,它的运行速度会快 5 倍,如果你想进一步优化,那么你可以借助 OpenCV 并行模块 cv::parallel_for() 来并行运行你的代码
  • 我以我的经验告诉你,因为 Opencv Python API 在内部使用 C++,如果你在 C++ 本身中迭代图像会快得多。
  • 基本上只做for i in range(1, ksize+1): left_thresh[:,i:] += img[:,:w-i]; right_thresh[:,:w-i] += img[:,i:]; up_thresh[i:,:] += img[:h-i,:]; down_thresh[:h-i,:] += img[i:,:]的循环——这里似乎减少了大约30-40%。
  • 进一步改进获得计算平均值所需的总和将使用 4x cv2.filter2D 与单行/列内核和适当的锚。

标签: python performance opencv numpy image-processing


【解决方案1】:

正如您在问题中暗示的那样,该函数的主要部分是获取计算平均值所需的 4 个总和数组 - 这里平均为整个函数 210 毫秒中的 190 毫秒。所以,让我们专注于此。

首先,必要的导入和方便的计时功能。

from timeit import default_timer as timer
import numpy as np
import cv2

## ===========================================================================

def time_fn(fn, img, ksize=20, iters=16):
    start = timer()
    for i in range(iters):
        fn(img, ksize)
    end = timer()
    return ((end - start) / iters) * 1000

## ===========================================================================
# Our test image
img = np.uint8(np.random.random((720,1280)) * 256)

原始实现

我们可以通过以下方式简化您的函数,使其仅计算并返回 4 个总和数组。我们稍后可以使用它来检查优化后的版本是否返回相同的结果。

# Original code
def windowed_sum_v1(img, ksize=20):
    left_thresh = np.zeros_like(img, dtype=np.float32)
    right_thresh = np.zeros_like(img, dtype=np.float32)
    up_thresh = np.zeros_like(img, dtype=np.float32)
    down_thresh = np.zeros_like(img, dtype=np.float32)

    for i in range(1, ksize+1): 
        roll_left = np.roll(img, -i, axis=1)
        roll_right = np.roll(img, i, axis=1)
        roll_up = np.roll(img, -i, axis=0)
        roll_down = np.roll(img, i, axis=0)

        roll_left[:,-i:] = 0
        roll_right[:,:i] = 0
        roll_up[-i:,:] = 0
        roll_down[:i,:] = 0

        left_thresh += roll_right
        right_thresh += roll_left
        up_thresh += roll_down
        down_thresh += roll_up

    return (left_thresh, right_thresh, up_thresh, down_thresh)

现在我们可以知道这个函数在本地机器上花费了多少时间:

>>> print "V1: %f ms" % time_fn(windowed_sum_v1, img, 20, 16)
V1: 188.572077 ms

改进 #1

numpy.roll 必然会涉及一些开销,但这里没有必要深入研究。请注意,滚动数组后,将溢出数组边缘的行或列清零。然后将其添加到累加器中。添加零不会改变结果,所以我们不妨避免这种情况。相反,我们可以只添加整个数组的渐进式更小和适当偏移的切片,避免 roll 并(在某种程度上)减少所需的添加总数。

# Summing up ROIs
def windowed_sum_v2(img, ksize=20):
    h,w=(img.shape[0], img.shape[1])

    left_thresh = np.zeros_like(img, dtype=np.float32)
    right_thresh = np.zeros_like(img, dtype=np.float32)
    up_thresh = np.zeros_like(img, dtype=np.float32)
    down_thresh = np.zeros_like(img, dtype=np.float32)

    for i in range(1, ksize+1): 
        left_thresh[:,i:] += img[:,:w-i]
        right_thresh[:,:w-i] += img[:,i:]
        up_thresh[i:,:] += img[:h-i,:]
        down_thresh[:h-i,:] += img[i:,:]

    return (left_thresh, right_thresh, up_thresh, down_thresh)

让我们测试一下并计时:

>>> print "Results equal (V1 vs V2): %s" % (np.array_equal(windowed_sum_v1(img), windowed_sum_v2(img)))
Results equal (V1 vs V2): True
>>> print "V2: %f ms" % time_fn(windowed_sum_v2, img, 20, 16)
V2: 110.861794 ms

这个实现只需要原来的 60% 的时间。我们能做得更好吗?

改进 #2

我们仍然有一个循环。如果我们可以通过对某个优化函数的一次调用来替换重复添加,那就太好了。一个这样的函数是cv2.filter2D,它计算以下内容:

我们可以创建一个内核,这样我们要添加的点的权重为1.0,而内核所锚定的点的权重为0.0

例如ksize=8时,我们可以使用以下内核和锚点位置。

函数如下:

# Using filter2d
def windowed_sum_v3(img, ksize=20):
    kernel_l = np.array([[1.0] * (ksize) + [0.0]])
    kernel_r = np.array([[0.0] + [1.0] * (ksize)])
    kernel_u = np.array([[1.0]] * (ksize) + [[0.0]])
    kernel_d = np.array([[0.0]] + [[1.0]] * (ksize))

    left_thresh = cv2.filter2D(img, cv2.CV_32F, kernel_l, anchor=(ksize,0), borderType=cv2.BORDER_CONSTANT)
    right_thresh = cv2.filter2D(img, cv2.CV_32F, kernel_r, anchor=(0,0), borderType=cv2.BORDER_CONSTANT)
    up_thresh = cv2.filter2D(img, cv2.CV_32F, kernel_u, anchor=(0,ksize), borderType=cv2.BORDER_CONSTANT)
    down_thresh = cv2.filter2D(img, cv2.CV_32F, kernel_d, anchor=(0,0), borderType=cv2.BORDER_CONSTANT)

    return (left_thresh, right_thresh, up_thresh, down_thresh)

再次,让我们测试一下这个函数的时间:

>>> print "Results equal (V1 vs V3): %s" % (np.array_equal(windowed_sum_v1(img), windowed_sum_v3(img)))
Results equal (V1 vs V3): True
>>> print "V2: %f ms" % time_fn(windowed_sum_v3, img, 20, 16)
V3: 46.652996 ms

我们减少了原来的 25%。

改进#3

我们正在处理浮点数,但现在我们不做任何除法,内核只包含 1 和 0。这意味着我们可能会使用整数。您提到最大窗口大小为 50,这意味着我们使用 16 位有符号整数是安全的。整数数学往往更快,如果我们使用的代码被正确矢量化,我们可能会一次处理两次。让我们试一试,我们还提供一个包装器,它以浮点格式返回结果,就像以前的版本一样。

# Integer only
def windowed_sum_v4(img, ksize=20):
    kernel_l = np.array([[1] * (ksize) + [0]], dtype=np.int16)
    kernel_r = np.array([[0] + [1] * (ksize)], dtype=np.int16)
    kernel_u = np.array([[1]] * (ksize) + [[0]], dtype=np.int16)
    kernel_d = np.array([[0]] + [[1]] * (ksize), dtype=np.int16)

    left_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_l, anchor=(ksize,0), borderType=cv2.BORDER_CONSTANT)
    right_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_r, anchor=(0,0), borderType=cv2.BORDER_CONSTANT)
    up_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_u, anchor=(0,ksize), borderType=cv2.BORDER_CONSTANT)
    down_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_d, anchor=(0,0), borderType=cv2.BORDER_CONSTANT)

    return (left_thresh, right_thresh, up_thresh, down_thresh)

# Integer only, but returning floats    
def windowed_sum_v5(img, ksize=20):
    result = windowed_sum_v4(img, ksize)
    return map(np.float32,result)

让我们测试一下。

>>> print "Results equal (V1 vs V4): %s" % (np.array_equal(windowed_sum_v1(img), windowed_sum_v4(img)))
Results equal (V1 vs V4): True
>>> print "Results equal (V1 vs V5): %s" % (np.array_equal(windowed_sum_v1(img), windowed_sum_v5(img)))
Results equal (V1 vs V5): True
>>> print "V4: %f ms" % time_fn(windowed_sum_v4, img, 20, 16)
V4: 14.712223 ms
>>> print "V5: %f ms" % time_fn(windowed_sum_v5, img, 20, 16)
V5: 20.859744 ms

如果我们可以使用 16 位整数,我们会降低到 7%,如果我们想要浮点数,我们会降低到 10%。

进一步改进

让我们回到您编写的完整阈值函数。我们可以对内核进行缩放,以便filter2D 直接返回平均值,而不是将总和作为单独的步骤来获得平均值。这只是很小的改进 (~3%)。

同样,您可以通过为filter2D 调用提供适当的delta 来替换C 的加法或减法。这又减少了几个百分点。

注意:如果您实施上述两个更改,您可能会遇到一些由于浮点表示的限制而产生的差异。

另一种可能性是进行确定掩码所需的比较是矩阵与标量的比较:

input < threshold
input - input < threshold - input
0 < threshold - input
0 < adjusted_threshold            # determined using adjusted kernel

我们可以通过修改内核来减去按适当权重缩放的锚像素的值 (ksize) 来实现这一点。使用 numpy,这似乎只有很小的区别,尽管按照我的理解,我们可以在算法的那部分保存一半的读取(而filter2D 可能仍然读取并乘以相应的值,即使权重是0).

阈值函数的最快实现

考虑到所有这些,我们可以像这样重写您的函数,并在大约 12.5% 的时间内获得与原始函数相同的结果:

def bilateral_adaptive_threshold5(img, ksize=20, C=0, mode='floor', true_value=255, false_value=0):
    mask = np.full(img.shape, false_value, dtype=np.uint8)

    kernel_l = np.array([[1] * (ksize) + [-ksize]], dtype=np.int16)
    kernel_r = np.array([[-ksize] + [1] * (ksize)], dtype=np.int16)
    kernel_u = np.array([[1]] * (ksize) + [[-ksize]], dtype=np.int16)
    kernel_d = np.array([[-ksize]] + [[1]] * (ksize), dtype=np.int16)

    if mode == 'floor':
        delta = C * ksize
    elif mode == 'ceil':
        delta = -C * ksize
    else: raise ValueError("Unexpected mode value. Expected value is 'floor' or 'ceil'.")

    left_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_l, anchor=(ksize,0), delta=delta, borderType=cv2.BORDER_CONSTANT)
    right_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_r, anchor=(0,0), delta=delta, borderType=cv2.BORDER_CONSTANT)
    up_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_u, anchor=(0,ksize), delta=delta, borderType=cv2.BORDER_CONSTANT)
    down_thresh = cv2.filter2D(img, cv2.CV_16S, kernel_d, anchor=(0,0), delta=delta, borderType=cv2.BORDER_CONSTANT)

    if mode == 'floor':
        mask[((0 > left_thresh) & (0 > right_thresh)) | ((0 > up_thresh) & (0 > down_thresh))] = true_value
    elif mode == 'ceil':
        mask[((0 < left_thresh) & (0 < right_thresh)) | ((0 < up_thresh) & (0 < down_thresh))] = true_value

    return mask

【讨论】:

  • 你太棒了!在阅读了您对我的原始帖子的评论后,我实际上已经使用 cv2.filter2D 实现了该版本,并且我的实现看起来与您的完全一样,这令人放心:)。我可以确认您所说的性能改进:它只需要原始时间的 25% 左右。将除法和加法直接合并到内核中是另一个好主意!我也会这样做,即使它只节省了几个百分点。我不能感谢你!事后看来,我原来使用 np.roll 的实现是如此不必要的,这似乎总是很明显......哦,这就是你学习的方式。
  • 感谢您提供教科书质量的答案,太棒了!不过有一件事:我不太明白上面定义的内核如何正确执行缩放。假设您在所有具有相同值a 的像素块上对内核进行卷积(关联)。然后kernel_l 将计算锚像素的结果为ksize * a + (-ksize) * a = 0,即使平均值应该是a。它必须是kernel_l = np.array([[1] * (ksize) + [-ksize+1]], dtype=np.int16)(注意末尾的+1),但即便如此,它也只是一般情况下真实平均值的近似值?
  • @Alex 它不再只是计算平均值,它还减去输入,以便我们稍后可以与标量 0 进行比较。因此,在您的示例中,0 是预期结果。我通过 ksize 常量来缩放所有内容,以消除除法的需要,让我们只使用整数。
  • 哦,是的,我现在明白了:将平均值与锚值进行比较相当于将总和与ksize乘以锚值进行比较,比较两个值相当于将它们的差异与零。简单的算术,我的错。谢谢你!
  • 我注意到了一个细节:在最后一个实现中,C 需要被 ksize 重新缩放,以便计算与原始函数等效。
猜你喜欢
  • 2020-08-01
  • 1970-01-01
  • 2021-06-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-10-17
  • 1970-01-01
  • 2016-09-27
相关资源
最近更新 更多