前言

传统机器视觉在图像识别领域虽然比不了神经网络,但也有一些可使用的场景。最近做了Kaggle上的dog-breed-identification的比赛,感觉神经网络太容易过拟合了,导致结果不一定好,仅仅进行调参也很难防止,导致花的时间多,但结果并没有变得更好。对于简单干扰较小的情况,机器视觉还是占有优势的。

物体与场景识别中,通过纹理特征进行识别也是很常用的手段,图像的纹理是描述图像中重复和规律性的局部结构信息,如平滑度、粗糙度、周期性等,它可以反映图像内物体表面的空间排列和灰度变化。通过对纹理特征的分析,我们可以进行图像分类、目标识别、图像分割等操作。LBP算法就是以像素为单位的基础,通过灰度值编码,来提取图像的纹理特征的图像特征描述算法。

本算法使用的测试素材如下:

material.png

传统LBP算法

算法原理

在一幅图像中,原始的LBP算子定义了一个3×3的滑动模版,对于一个给定的像素点x,在滑动模版的范围内(四周)相邻的8个像素将与x进行减法操作,得到8个不同的相对灰度值。其中,若相对灰度值大于等于的灰度值,则编码为1,否则为0。从左上角的像素点开始按照顺时针拼接这些数(0或1),如此可以得到一个8位二进制数,将这个二进制数转化为10进制数作为x的灰度值。整个处理过程可用以下公式表达:

其中

  • I(c) - 为中心坐标的灰度值

  • I(p) - 周围八个像素点的灰度值

  • s(x)函数表达式如下:

整个过程示意图如下所示:

lbp1.jpg

按照顺时针的方向,可以将上图中相对灰度值拼接为”01111100”,转化为10进制为”124”,将”124”作为上图中中间像素的值,当然如果算出的灰度值超过了图像灰度值范围,还需要进行归一化,将灰度值放入[0,255]的范围中。

代码实现

按照算法原理,可以将实现方法分割为以下步骤:

  1. 初始化图像:图像灰度化、边缘填充、转化数据类型(int8的数据类型支持范围太小)。
  2. 定义LBP算法:遍历图像所有像素点,按照LBP(xc+yc)公式进行计算。
  3. 定义s(x)。
  4. 编码并转码:按照顺时针得到8为二进制数并将它转化为10进制。
  5. 展示图像:我们一般使用像素直方图将处理结果展示出来。

导入需要使用到的Python库:

1
2
3
4
import cv2
import time
import numpy as np
import matplotlib.pyplot as plt

初始化图像

  1. 对图像进行灰度化处理。cv2.imread(image, 0)cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
  2. 为了让图像的边缘像素也能进行LBP编码,我们需要在图像四周添加1像素的边缘填充。OpenCV中已经提供了相应的方法,cv2.copyMakeBorder(image, 1, 1, 1, 1, borderType=cv2.BORDER_REPLICATE),其中borderType为填充的方法,cv2.BORDER_REPLICATE指的是以边缘像素进行填充。
  3. 转化图像中像素值的类型为float或者int32

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def init_image(input_path):
"""
初始化图像
:param input_path: 图像路径
:return: 处理后的图像
"""
# 以单通道的形式读取图像
image = cv2.imread(input_path, 0)
# image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 给图像填充边缘像素
image = cv2.copyMakeBorder(image, 1, 1, 1, 1, borderType=cv2.BORDER_REPLICATE)
h, w = image.shape[0], image.shape[1]
# 将灰度值转化为int32,方便后续计算
image = image.astype(np.int32)
image_name = input_path.split("/")[-1]
print(f"图像名:{image_name},高:{h}, 宽:{w}")
return image

定义LBP算法并转码

我们需要循环图像中所有的像素点,计算部分按照LBP(xc+yc)公式编写即可。得到周围的8个LBP编码值后,我使用list存储,后续拼接为string类型,转化10进制数可以使用int(str, 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
def LBP(image):
"""
LBP算法
:param image: 待处理图像
:param h: 图像的
:param w:
:return:
"""
h, w = image.shape[0], image.shape[1]
# 处理后的图像
temp = np.zeros((h - 1, w - 1))
print("---图像处理中---")
for i in range(1, h - 1):
for j in range(1, w - 1):
calculation_list = list()
calculation_list.append(image[i - 1][j - 1] - image[i][j])
calculation_list.append(image[i][j - 1] - image[i][j])
calculation_list.append(image[i + 1][j - 1] - image[i][j])

calculation_list.append(image[i + 1][j] - image[i][j])
calculation_list.append(image[i + 1][j + 1] - image[i][j])

calculation_list.append(image[i][j + 1] - image[i][j])
calculation_list.append(image[i - 1][j + 1] - image[i][j])
calculation_list.append(image[i - 1][j] - image[i][j])
# 将所得值转化为0,1
for k in range(8):
calculation_list[k] = transform(calculation_list[k])
# 获得二进制码
result_str = "".join([str(i) for i in calculation_list])
# 转化为十进制并存入图像
temp[i - 1][j - 1] = int(result_str, 2)
# print(result_str)
# 将像素值归一化
temp = np.clip(temp, 0, 255).astype("uint8")
# 转化通道,方便后续展示
temp = cv2.cvtColor(temp, cv2.COLOR_BGR2RGB)
print("---图像处理完成---")
return temp

其中transform函数即为公式中的s(x)。

1
2
3
4
5
def transform(num):
if num > 0:
return 1
else:
return 0

展示处理结果

对于这种需要进行对比的算法,一般都会采用Matplotlib进行图像的展示,这样会更利于结果的对比。使用Matplotlib展示要注意颜色格式的不同,需要将常规的BGR转化为RGB格式,转化方法为:cv2.cvtColor(temp, cv2.COLOR_BGR2RGB)

对于像素直方图,我们可以使用plt.hist(image,nut)进行绘制,其中:

  • image: 需要绘制的值,这边可以直接使用image.ravel()h*w的矩阵转化为1*(h*w)的格式。
  • nut: 分组间隔
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
def show_result(image_list, title_list):
"""
展示处理后的图像及其像素直方图
:param image_list: 图像列表
:param title_list: 标题列表
:return: None
"""
# 设定图的大小
plt.figure(figsize=(8, 5), dpi=100)
# 绘图
for i in range(4):
plt.subplot(2, 2, i + 1)
plt.title(title_list[i])
# 将直方图放在第二列
if i % 2 == 1:
plt.xticks(range(0, 257, 16), range(0, 17))
plt.hist(image_list[int(i / 2)].ravel(), 16)
plt.xlabel("Dimension")
plt.ylabel("Value")
# 处理图放在第一列
else:
plt.axis("off")
plt.imshow(image_list[int(i / 2)])
# 紧凑排布
plt.tight_layout()
plt.show()

测试代码与结果展示

1
2
3
4
5
6
7
8
9
10
11
12
if __name__ == '__main__':
input_path1 = "input/lane1.jpg"
image = init_image(input_path1)
result1 = LBP(image)

input_path2 = "input/lane2.jpg"
image = init_image(input_path2)
result2 = LBP(image)

image_list = [result1, result2]
title_list = ["lane1_LBP", "lane1_LBP_hist", "lane2_LBP", "lane2_LBP_hist"]
show_result(image_list, title_list)

输出结果为:

lbp_run.png
lbp_result.png

处理的速度还是比较快的,可以看到除了14-15这个分段值有些不同,其余各数值排布的值几乎完全一致。这说明这两张照片所展示的场景非常相似。

Circular LBP算法

算法原理

我们已经知道了传统LBP算法的基本原理。我们可以想象一下,如果我们将图像旋转,编码后的值是否一样呢?答案是否定的,具体原因如下图:

lbp2.png

可以看到现在拼接后的字符串为”00011111”,转化为10进制为”31”,而不是原来的”124”。图像只是旋转了90°,但图像的实际内容并没有任何变化,所以传统的LBP算法是不具备旋转不变形的,这对于识别来说是相当不利的。
而且由于传统LBP采用的是3×3的模版,因此对周围像素的依赖是比较大的,可能会受到噪声的干扰。而且在一些复杂场景下,也没法根据情况进行参数调整。

总的来说,传统LBP算法虽然有着灰度不变性,但由于LBP值是按照顺时针进行编码,因此并不具有旋转不变性,周围八个像素也不能满足不同尺寸和频率纹理的需要。为了解决这些问题,后续的学者们也提出了很多的方法,我们只展示较为简单的Circular LBP算法。

相比于传统LBP算法,Circular LBP是以R为半径的圆作为取点范围,在圆形区域内选择P个像素点替代中心像素点周围的八个像素点,其中第i个像素点的坐标计算公式如下:

其中

  • (xc,yc) - 中心点坐标

  • R - 圆形区域半径

  • P - 选取点的总个数。

如何取值的示意如下图所示。

circle_P.png

对于计算后结果不为整数的坐标值,在图像中是无法找到的,这时候可以使用双线性插值的计算值作为该坐标点的像素值。

最后,为了保证图像的旋转不变性,还需要进行如下操作:
将所得二进制字符串按照顺时针旋转顺序不断更换起始点,选取其中转化为10进制的最小值作为该点的LBP值,示意图如下所示。

circle_process.png

Circular LBP算法通过圆形邻域、插值和旋转不变性的增强,使得相似纹理区域的编码更加一致。这种一致性有助于我们从图像中提取出更稳定、更准确的纹理特征。

代码实现

在了解了原理之后,我们可以大致可以得到如下的实现步骤:

  1. 图像初始化处理:灰度化、边缘填充、转化数据类型
  2. 定义双线性插值:对于计算出非整数的坐标,可以采用双线性插值得到该坐标点的像素值
  3. 在2进制字符串中找到转码后10进制的最小值
  4. 定义Circlar LBP的计算:按照公式进行,有一些细节需要注意
  5. 结果展示与分析:相比传统LBP有什么优点,性能如何

初始化处理

因为使用的是以R为圆心的圆形范围,因此我们在进行边缘填充时,需要按照R的大小进行填充。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def init_image(input_path, R):
"""
初始化图像
:param input_path: 图像路径
:param R:范围圆的半径
:return:处理后的图像
"""
# 单通道读取
image = cv2.imread(input_path, 0)
h, w = image.shape[0], image.shape[1]
# 四周添加R的边缘像素
image = cv2.copyMakeBorder(image, R, R, R, R, borderType=cv2.BORDER_REPLICATE)
# 转化数据类型为int32,方便后续计算
image = image.astype(np.int32)
image_name = input_path.split("/")[-1]
print(f"图像名:{image_name},高:{h}, 宽:{w}")
return image

定义双线性插值

在计算坐标点时,由于包括三角函数计算,因此很容易得出非整数值。当计算出的像素点坐标无法在图像中找到时,就可以使用双线性插值的方法计算出该点灰度值。

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
def bilinear_interpolation(xp, yp, image):
"""
双线性插值
:param xp: x坐标
:param yp: y坐标
:param image: 输入的图像
:return: 计算后所得(x,y)的灰度值
"""
h, w = image.shape[0], image.shape[1]
# 向下取整
x1, y1 = int(xp), int(yp)
# 获得四点的坐标
if x1 > w - 1:
x2 = x1 - 1
else:
x2 = x1 + 1
if y1 > h - 1:
y2 = y1 - 1
else:
y2 = y1 + 1
f_x1y1 = image[y1, x1]
f_x1y2 = image[y2, x1]
f_x2y1 = image[y1, x2]
f_x2y2 = image[y2, x2]
# 计算插值
pixel_value = f_x1y1 * (x2 - xp) * (y2 - yp) + f_x2y1 * (xp - x1) * (y2 - yp) + \
f_x1y2 * (x2 - xp) * (yp - y1) + f_x2y2 * (xp - x1) * (yp - y1)
return pixel_value

找到最小的转码值

按照顺时针方向,转化起点的过程,我采用的是不断交换首尾字符的方法,不断变换2进制字符串的起点,因此可以找到所有2进制字符串中转化为10进制后最小的那个值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_min(str, p):
"""
得到最小值编码
:param str:二进制字符串
:param p:获取点的个数
:return:十进制最小的值
"""
min = int(str, 2)
for i in range(len(str) - 1):
temp = str[1:p]
temp = temp + str[0]
str = temp
if int(str, 2) < min:
min = int(str, 2)
return min

定义Circular_LBP算法

这个过程按照公式编写即可,但有四个点需要注意:

  1. 对于三角函数运算,需要现将计算所得弧度制其转化为角度制,可以使用np.radians()方法。

  2. 在我们的坐标值计算公式中,索引都是以1为起点(数学坐标系)。因此为了对应图像坐标系,我们在计算坐标时进行减一操作,不然会出现索引越界的情况。

  3. 由于计算所得的xpyp必定为float类型,因此即便是整数,也会多显示一位小数0(如35.0),为此,我们需要使用num.is_integer()的方法进行整数的判断,这样才能精确根据所得坐标值进行不同的操作。

  4. 因为计算复杂,因此使用time模块进行计算运行时间,来估量程序的运行效率。使用案例为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 开始计时,记录程序开始的时刻
    start_time = time.time()
    """
    程序运行代码
    """
    # 记录运行结束时刻
    end_time = time.time()
    # 计算总时长
    all_time = end_time - start_time

实现代码如下:

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
def circular_extended_lbp(image_path, r, p):
# 初始化图像
image = init_image(image_path, r)
# 获得图像的宽高
h, w = image.shape[0], image.shape[1]
print(f"开始LBP处理,设定参数:半径:{r} 点个数:{p}")
start_time = time.time()
# 处理后的图像
temp = np.zeros((h - r, w - r))
for i in range(r, h - r):
for j in range(r, w - r):
calculation_list = list()
for k in range(p):
# 转化为弧度制
radians = np.radians(2 * np.pi * k / float(p))
# 为了对应图像坐标系进行减一操作
xp = j + r * np.cos(radians) - 1
yp = i - r * np.sin(radians) - 1
xp = init_integer(xp)
yp = init_integer(yp)
# print(xp, yp)
# 计算与圆心点的灰度值的差值
try:
calculation_list.append(image[xp][yp] - image[i][j])
except:
calculation_list.append(bilinear_interpolation(xp, yp, image) - image[i][j])
# 获得二进制字符串
calculation_list[k] = transform(calculation_list[k])
# print(calculation_list)
result_str = "".join([str(i) for i in calculation_list])
# 得到二进制中最小的十进制编码数
temp[i - r][j - r] = get_min(result_str, p)
temp = np.clip(temp, 0, 255).astype("uint8")
temp = cv2.cvtColor(temp, cv2.COLOR_BGR2RGB)
end_time = time.time()
all_time = end_time - start_time
print(f"LBP处理完毕,耗时:{int(all_time // 60)}m{int(all_time % 60)}s")
print("-" * 100)
return temp

其中init_integer函数为:

1
2
3
4
5
def init_integer(num):
if num.is_integer():
return int(num)
else:
return num

展示结果

展示部分的代码与之前代码并无差别,因此不做展示,直接展示测试代码与结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if __name__ == '__main__':
# 圆的半径
R = 2
# 取多少个点
P = 6
input_path = "./input/lane1.jpg"
result_image1 = circular_extended_lbp(input_path, R, P)

input_path2 = "./input/lane2.jpg"
result_image2 = circular_extended_lbp(input_path, R, P)

image_list = [result_image1, result_image2]
title_list = ["lane1_circular_LBP", "lane1_circular_LBP_hist", "lane2_circular_LBP", "lane2_circular_LBP_hist"]
show_result(image_list, title_list)

结果展示:

circle_run.png
circluar_result.png

可以看出,用Circular LBP处理后的图像,像素直方图几乎完全一致,且分布较为单一。这说明Circular LBP通过圆形邻域、插值和旋转不变性的增强,使得相似纹理区域的编码更加一致。表现在结果上,即既能得出结论的同时,抵抗干扰的能力会比传统的LBP算法要强。最后根据运行时间可以看出,该程序的运行效率不高,还有优化的空间。