本文文件仓库

本文所用到的所有内容均保存在该仓库中:https://github.com/chmoe/FaceRecognize_Superpixel_Colorblock

运行环境

项目版本
DeviceMacMini(2020) M1
OSmacOS Big [email protected]
RAM16G
SSD1T
Python3.8.6 based conda
IDEPyCharm 2021.2.2(Community Edition)

面部识别

具体要求

2021年9月24日,教授为我布置了研究生的第一个课题,目的是测试我的编程能力的样子。内容是使用一下四种面容检测器框出人脸。

  • Haar特徴量 + Cascade識別器
  • HOG特徴量 + SVM識別器
  • CNN
  • MTCNN

参考链接:https://iatom.hatenablog.com/entry/2020/11/01/152307

实现代码

Haar特徴量 + Cascade識別器

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
# -*- coding: UTF-8 -*-
# @Project: Face_recognition
# @File: haar_cascade
# @Author: rtmacha
# @Date: 2021/09/25 9:06

import cv2 # conda install opencv
import copy

# OpenCV
cascade_fn = "./haarcascade_frontalface_alt.xml"
face_cascade = cv2.CascadeClassifier(cascade_fn) # 级联分类器(滑动窗口+级联分类器)

for i in range(37):

img = cv2.imread(r'./picture/' + str(i) + '.jpg')
img = cv2.resize(img, dsize=(480, 640))

face_frame = copy.deepcopy(img)

gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 转换为灰度图片
gray_img = cv2.equalizeHist(gray_img) # 图片平滑化(直方图均衡化,用于提高图像的质量)

# 检测出人脸的四个点(起始坐标x,y xy方向的长度)
dets = face_cascade.detectMultiScale(gray_img, scaleFactor=1.3, minNeighbors=3, minSize=(30, 30),
flags=cv2.CASCADE_SCALE_IMAGE)

for (x, y, w, h) in dets:
# 顔のトリミング
face_image = face_frame[y:y + h, x:x + w]
cv2.putText(img, "Haar", (x, y - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# 顔箇所を四角で描画
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
# 只保存画出的人脸
cv2.imwrite('./result/haar_face/' + str(i) + '.png', img)

HOG特徴量 + SVM識別器

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
# -*- coding: UTF-8 -*-
# @Project: Face_recognition
# @File: Dlib_HOG_CSM
# @Author: rtmacha
# @Date: 2021/09/25 13:40

import cv2 # conda install opencv
import dlib # github下载源码编译
import copy

print(dlib.__file__)

# Dlib
detector = dlib.get_frontal_face_detector()

for i in range(37):

img = cv2.imread(r'./picture/' + str(i) + '.jpg')
img = cv2.resize(img, dsize=(480, 640))

face_frame = copy.deepcopy(img)

# 检测出人脸的四个点(起始坐标x,y xy方向的长度)
dets = detector(img, 1)

for k, d in enumerate(dets):
# 顔のトリミング
face_image = face_frame[d.top():d.bottom(), d.left():d.right()]

# Dlib名を書き込み
cv2.putText(img, "Dlib", (int(d.left()), int(d.top()) - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1,
cv2.LINE_AA)
# 顔箇所を四角で描画
cv2.rectangle(img, (int(d.left()), int(d.top())), (int(d.right()), int(d.bottom())), (0, 255, 0), 2)
# 顔だけをファイルに保存
cv2.imwrite('./result/Dlib_HOG_CSM/' + str(i) + '.png', img)

CNN

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
# -*- coding: UTF-8 -*-
# @Project: Face_recognition
# @File: CNN
# @Author: rtmacha
# @Date: 2021/09/25 14:07

import dlib
import cv2
import copy

# CNN
cnn_fn = './mmod_human_face_detector.dat'
cnn_face_detector = dlib.cnn_face_detection_model_v1(cnn_fn)

for i in range(37):

img = cv2.imread(r'./picture/' + str(i) + '.jpg')
img = cv2.resize(img, dsize=(480, 640))

face_frame = copy.deepcopy(img)

# 检测出人脸的四个点(起始坐标x,y xy方向的长度)
dets = cnn_face_detector(img, 1)

for face in dets:
# 顔のトリミング
face_image = face_frame[face.rect.top():face.rect.bottom(), face.rect.left():face.rect.right()]

cv2.putText(img, "CNN", (int(face.rect.left()), int(face.rect.top()) - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
(0, 255, 0), 1, cv2.LINE_AA)
# 顔箇所を四角で描画
cv2.rectangle(img, (face.rect.left(), face.rect.top()), (face.rect.right(), face.rect.bottom()), (0, 255, 0),
2)
# 顔だけをファイルに保存
cv2.imwrite('./result/CNN/' + str(i) + '.png', img)

MTCNN

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
# -*- coding: UTF-8 -*-
# @Project: Face_recognition
# @File: CNN
# @Author: rtmacha
# @Date: 2021/09/25 14:07

import cv2
from mtcnn import MTCNN # conda install mtcnn
import copy

# detector
detector = MTCNN()

for i in range(37):

img = cv2.imread(r'./picture/' + str(i) + '.jpg')
img = cv2.resize(img, dsize=(480, 640))

face_frame = copy.deepcopy(img)

# BGR2RGB
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

# 检测出人脸的四个点
dets = detector.detect_faces(img_rgb)

for face in dets:
# 座標と高幅を取得
box_x, box_y, box_w, box_h = face['box']
# 顔のトリミング
face_image = face_frame[box_y:box_y + box_h, box_x:box_x + box_w]

# 顔箇所にMTCNN文字を表示
cv2.putText(face_frame, "MTCNN", (box_x, box_y - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# 顔箇所を四角で描画
cv2.rectangle(face_frame, (box_x, box_y), (box_x + box_w, box_y + box_h), (0, 255, 0), 2)

for key, value in face['keypoints'].items():
# 顔のランドマーク箇所に小さい赤色サークルを描画(目、鼻、口)
cv2.circle(face_frame, value, 1, (0, 0, 255), -1)
# 顔だけをファイルに保存
cv2.imwrite('./result/MTCNN/' + str(i) + '.png', face_frame)


总结

上述四个方式的实现并不需要过多的代码,仅仅是使用网路上下载的意境训练好的人脸识别的模型进行应用一下即可。

超像素处理

具体要求

2021年9月27日,教授布置了第二个研究生课题,内容是使用面部识别中第四种方式(MTCNN)所得到的人脸,将框处的人脸部分使用SLIC(Simple Linear Iterative Clustering,简单的线性迭代聚类)进行超像素化处理,并且将结果输出。

参考链接:https://techblog.nhn-techorus.com/archives/7793

解析及想法

想法

看过众多的解释之后,在这里我想写下自己的理解。

所谓超像素,并非从名字的理解上,将图片的像素数进行增加,而是某种意义上降低图片的像素数目。

这里说的某种意义上是因为其并非真正降低了图片所存在的像素数目,只是将相同颜色的像素数理解成为同一像素。例如经过超像素化后的图片(如下图),其输出结果分辨率为192×226192 \times 226,但是在肉眼看上去,其色块只有6×76\times7(图片中每一个色块中心的点为颜色的定位点,在这里需要忽略考虑)。

所以说这个处理为什么要叫做超像素(Superpixel),而不能够叫做减像素(Fallpixel)呢?

理解了这一点,那么接下来就可以继续思考了。

根据网路查阅资料显示,使用该方法首先需要将图片读取为Lab的色彩表示方式。至于为什么如此,看了网络上列举的很多理由,诸如Lab相较于RGB更加接近于人类生理视觉等各种原因,我还是无法理解其原因。不过作为初学者,本项目的重点是学会如何使用,而非探求其太过于细节的部分,因此这一点就让我直接踩在巨人的肩膀上,不过多过问好了。

具体步骤

为了实现超像素(我真的不想使用这个名字),根据上文分析的原理,可以分为下述几个步骤进行实现。

  1. 均匀播种
  2. 查询周边
  3. 找到同类
  4. 交给时间

下面对此进行一一介绍

均匀播种

首先为了实现超像素化,需要在图片中找到需要划分个数的颜色点作为超像素的中心点。但是,作为计算机,一个笨笨的家伙,她看图的方式和人类看图的方式是不同的,是没有办法从图片整体下手的,只能够一个像素点一个像素点的进行检查(这里不考虑特征提取),所以是没有办法直觉判断应该选择哪个像素作为中心点的(当然按照数组迷宫的方式进行计算的话也是可以的,但是那样只会增加无形的计算量,所以不采取这种方式)。

因此,干脆直接让计算机平均的将图片划分为需要划分个数个块,并且将每个块的中心点作为色块的中心点。

具体实现方式,就是在图像内均匀分配需要划分个数个像素点作为种子点。

这里假设需要划分个数=K,图片的总像素值为N,那么每个超像素的大小便为N/K,这里的每个超像素应近似为正方形,因此每个种子相邻的距离(步长)可以近似为S=NKS=\sqrt{\frac{N}{K}}

其具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
def init_clusters(self):
"""
初始化超像素
:return: self
"""
h = self.S / 2
w = self.S / 2
while h < self.image_height: # 不超越高度的范围
while w < self.image_width: # 不超越宽度的范围
self.clusters.append(self.make_cluster(h, w)) # 保存每一个超像素
w += self.S # 向右移动s宽度
w = self.S / 2 # 横向从头开始
h += self.S # 纵向向下移动一个s宽度

查询周边

经过上一个步骤的处理,我们成功的在图片上放置了K个超像素点(种子)。

正常情况下来说,我们会要求超像素的种子落在比较正常的地方,也就是说,不希望种子位于超像素的分界位置,更不希望种子所选的地方恰巧位于噪点上。

这里的噪点可以形象的想象为显示器中的坏点,或者可以理解为万花丛中一点绿,因为那一点点绿无法用于表示整个花丛中的颜色。

基于上述原理,我们需要对该种子周围的地方进行判别,以减小或防止上述两个问题的发生。

因此需要对该种子为中心的n*n范围内的像素点计算梯度值,并且找到梯度值最小的点作为新的该区域的新的种子点,并将种子移动到该像素。

根据参考链接中的「图像梯度的基本原理」文章所述,图像的梯度相当于两个相邻像素点之间的差值,因此就有如下的梯度计算方式了,其对应代码如下。

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
def get_gradient(self, h, w):
"""
获取梯度
:param h:
:param w:
:return:
"""
# 检查是否超出边界
if w + 1 >= self.image_width:
w = self.image_width - 2
if h + 1 >= self.image_height:
h = self.image_height - 2

gradient = self.data[h + 1][w + 1][0] - self.data[h][w][0] + \
self.data[h + 1][w + 1][1] - self.data[h][w][1] + \
self.data[h + 1][w + 1][2] - self.data[h][w][2]
return gradient

def move_clusters(self):
# 找到梯度最小的超像素
# cluster的形状[h, w, l, a, b]
for cluster in self.clusters: # 遍历所有超像素
cluster_gradient = self.get_gradient(cluster.h, cluster.w)
# 梯度下降(检查以该点为中心的3*3的范围)
for dh in range(-1, 2):
for dw in range(-1, 2):
_h = cluster.h + dh
_w = cluster.w + dw
new_gradient = self.get_gradient(_h, _w)
if new_gradient < cluster_gradient: # 找到梯度更小的
cluster.update(_h, _w, self.data[_h][_w][0], self.data[_h][_w][1],
self.data[_h][_w][2]) # 更新超像素位置
cluster_gradient = new_gradient # 暂存新的超像素

找到同类

到了这一步,种子点已经找到了一个相对较好的位置了。

接下来要做的,是在每个种子点周围找到属于该种子点的像素,并且将该像素归于该种子点所在的超像素块中。

在前文,我们计算过每一个超像素之间的距离(步长)应为S,但是我们并不能够确定当前超像素块所包含的像素都能够聚集在这大小为S*S的范围内,因此这里将其搜索范围设定为2S*2S

在搜索的过程中,我们需要对每一个像素进行计算,找到与该像素距离最近的超像素并且将其划分到该超像素中。

注意:这里的距离需要计算空间距离和颜色距离。

如果只计算空间距离的话,最后划分的结果应该会为非常标准的正方形,但是在真实的图像中,不难见到类似于融化感觉的图片,这个时候划分就会出错。

在开始的时候,我们导入的图像颜色空间是Lab的,因此在这里计算颜色距离时,我们也需要使用该方式进行计算。由于颜色是用三维数据表示的,因此在计算颜色距离的时候,可以考虑成计算三维空间坐标系中两个点的距离,即

dc(olor)=(ljli)2+(ajai)2+(bjbi)2d_{c(olor)}=\sqrt{(l_j-l_i)^2+(a_j-a_i)^2+(b_j-b_i)^2}

在计算空间距离的时候,相当于计算二维坐标系下两个点之间的距离,即使用两点坐标公式即可计算

ds=(xjxi)2+(yjyi)2d_s=\sqrt{(x_j-x_i)^2+(y_j-y_i)^2}

那么如何将这两个距离联合呢,在这里我并没有找到相关资料进行证明,根据其他讲述SLIC(参考内容中有链接)的文章所述,需要使用下述公式进行计算。

D=(dcNc)2+dsNsD'=\sqrt{(\frac{d_c}{N_c})^2+\frac{d_s}{N_s}}

并且NsN_s表示距离空间中的最大值,也就是超像素之间的步长即S,而NcN_c所代表的是颜色距离的最大值,常会使用常数m来代表,并且这个m是可以自主修改的,范围是[1, 40],一般会取10。

因为在这里的检索范围是2S*2S,所以同一个像素点可能会被多个超像素种子点进行检查,因此需要记录像素点到所有检索到其的超像素块的种子点的综合距离,并且找到最小的距离,将当前像素归于距离最小的种子点所在的超像素。

遍历所有像素点,并且按照距离分类好超像素块之后,需要对每一个超像素块中的种子点进行更新,将种子点移动到该超像素块的最中间的点。

其实现如下

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
def assignment(self):
# 为每个像素分配超像素(保存在超像素中)
for cluster in self.clusters: # 遍历超像素
for h in range(cluster.h - 2 * self.S, cluster.h + 2 * self.S): # 遍历超像素周围2S大小的区域
if h < 0 or h >= self.image_height: # 超出高度范围
continue
for w in range(cluster.w - 2 * self.S, cluster.w + 2 * self.S): # 遍历超像素周围2S大小的区域
if w < 0 or w >= self.image_width: # 超出宽度范围
continue

# 计算度量
L, A, B = self.data[h][w] # 暂存当前坐标的lab值
Dc = math.sqrt( # 颜色距离
math.pow(L - cluster.l, 2) +
math.pow(A - cluster.a, 2) +
math.pow(B - cluster.b, 2))
Ds = math.sqrt( # 距离距离
math.pow(h - cluster.h, 2) +
math.pow(w - cluster.w, 2))
# 距离度量
D = math.sqrt(math.pow(Dc / self.M, 2) + math.pow(Ds / self.S, 2))

if D < self.dis[h][w]: # 找到距离更小的(2S的度量,原来不属于当前超像素的也会被算在其中)
if (h, w) not in self.label: # 当前点没有记录超像素
self.label[(h, w)] = cluster # 将当前点的超像素设置为当前超像素
cluster.pixels.append((h, w)) # 存储待更新超像素的像素点(后续需要专门的位置去进行更新)
else: # 当前点有记录超像素
self.label[(h, w)].pixels.remove((h, w)) # 从原来的点的超像素的记录中移除当前点
self.label[(h, w)] = cluster # 将当前点的超像素更新为现在的超像素
cluster.pixels.append((h, w)) # 记录当前的点
self.dis[h][w] = D

def update_cluster(self):
"""
将超像素的lab值取得属于当前超像素的所有像素的最中间的值
:return:
"""
for cluster in self.clusters: # 遍历超像素
sum_h = sum_w = number = 0
for p in cluster.pixels: # 遍历超像素的保存的内容,并且将其做平均化处理
sum_h += p[0] #
sum_w += p[1]
number += 1
_h = int(sum_h / number)
_w = int(sum_w / number)
# 将超像素的lab值取得属于当前超像素的所有像素的最中间的值
cluster.update(_h, _w, self.data[_h][_w][0], self.data[_h][_w][1], self.data[_h][_w][2])

交给时间

上述过程如果只进行一次的话,很有可能会不准确。

因此需要对上一个步骤重复进行(不断迭代),直到聚类的中心点不再发生变化为止。

对于这句突然的内容我实在是无法理解,因此去看了下原文

The L2 norm is used to compute a residual error E between the new cluster center locations and previous cluster center locations. The assignment and update steps can be repeated iteratively until the error converges, but we have found that 10 iterations suffices for most images, and report all results in this paper using this criteria.

L2范数用于计算新生成的超像素中心点和原来的中心点之间的残差,不断重复迭代assignmentupdate两个函数直到残差收敛。在论文中表示,经过实验发现对于大多数的图像来说,迭代10次都能够得到比较理想的结果。

综上,需要设置一个循环,并且循环次数设定为10,不断重复调用上一个步骤中的两个函数,即assignmentupdate两个函数。

其代码实现如下

1
2
3
for _ in trange(10):
self.assignment()
self.update_cluster()

实现代码

MTCNN部分

超像素处理部分所需要使用的图片需要经过MTCNN方式的检测,并且和第三部分面部识别的需求不同,因此需要对该部分代码进行修改,修改后的代码如下

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
# -*- coding: UTF-8 -*-
# @Project: SLIC
# @File: MTCNN
# @Author: rtmacha
# @Date: 2021/09/28 9:00

import cv2
from mtcnn import MTCNN # conda install mtcnn
import copy

# detector
detector = MTCNN()

for i in range(37):

img = cv2.imread(r'./picture/' + str(i) + '.jpg')
img = cv2.resize(img, dsize=(480, 640))

face_frame = copy.deepcopy(img)

# BGR2RGB
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

# 检测出人脸的四个点
dets = detector.detect_faces(img_rgb)

for face in dets:
# 座標と高幅を取得
box_x, box_y, box_w, box_h = face['box']
# 顔のトリミング
face_image = face_frame[box_y:box_y + box_h, box_x:box_x + box_w]

# 顔だけをファイルに保存
cv2.imwrite('./result/' + str(i) + '.png', face_image)

SLIC部分

该算法的伪代码如下图所示

代码解释

  • cluster[]: 用于保存为超像素位置
  • label{}: 用于保存每个像素对应的超像素

具体实现如下

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# -*- coding: UTF-8 -*-
# @Project: SLIC
# @File: SLIC
# @Author: rtmacha
# @Date: 2021/09/28 9:02

import math
from skimage import io, color
import numpy as np
from tqdm import trange


class Cluster(object):
cluster_index = 1

def __init__(self, h, w, l=0, a=0, b=0):
self.update(h, w, l, a, b)
self.pixels = [] # 超像素对应的像素点
self.no = self.cluster_index
Cluster.cluster_index += 1

def update(self, h, w, l, a, b):
self.h = h
self.w = w
self.l = l
self.a = a
self.b = b

def __str__(self):
return "{},{}:{} {} {} ".format(self.h, self.w, self.l, self.a, self.b)

def __repr__(self):
return self.__str__()


class SLICProcessor(object):
@staticmethod
def open_image(path):
"""
打开图片
Return:
高(row), 宽(col), 颜色([lab])
"""
rgb = io.imread(path)
lab_arr = color.rgb2lab(rgb)
return lab_arr # shape=(341, 266, 3)

@staticmethod
def save_lab_image(path, lab_arr):
"""
将图片从lab转换回rgb,然后保存到指定的path
:param path:
:param lab_arr:
:return:
"""
rgb_arr = color.lab2rgb(lab_arr)
io.imsave(path, rgb_arr)

def make_cluster(self, h, w):
h = int(h)
w = int(w)
# data是指原本的图片文件
return Cluster(h, w,
self.data[h][w][0], # l
self.data[h][w][1], # a
self.data[h][w][2]) # b

def __init__(self, filename, K, M):
self.K = K # 分割为K个S*S的大小
self.M = M

self.data = self.open_image(filename)
self.image_height = self.data.shape[0] # 图片高度 341
self.image_width = self.data.shape[1] # 图片宽度 266
self.N = self.image_height * self.image_width # 图片像素 90706
self.S = int(math.sqrt(self.N / self.K)) # N个像素平均分为k个超像素,每个超像素边长(相邻种子距离,即步长)

self.clusters = [] # 超像素的位置
self.label = {} # 每个像素属于哪个超像素
# dis: 像素到超像素的距离
self.dis = np.full((self.image_height, self.image_width), np.inf)

def init_clusters(self):
"""
初始化超像素
:return: self
"""
h = self.S / 2
w = self.S / 2
while h < self.image_height: # 不超越高度的范围
while w < self.image_width: # 不超越宽度的范围
self.clusters.append(self.make_cluster(h, w)) # 保存每一个超像素
w += self.S # 向右移动s宽度
w = self.S / 2 # 横向从头开始
h += self.S # 纵向向下移动一个s宽度

def get_gradient(self, h, w):
"""
获取梯度
:param h:
:param w:
:return:
"""
# 检查是否超出边界
if w + 1 >= self.image_width:
w = self.image_width - 2
if h + 1 >= self.image_height:
h = self.image_height - 2

gradient = self.data[h + 1][w + 1][0] - self.data[h][w][0] + \
self.data[h + 1][w + 1][1] - self.data[h][w][1] + \
self.data[h + 1][w + 1][2] - self.data[h][w][2]
return gradient

def move_clusters(self):
# 找到梯度最小的超像素
# cluster的形状[h, w, l, a, b]
for cluster in self.clusters: # 遍历所有超像素
cluster_gradient = self.get_gradient(cluster.h, cluster.w)
# 梯度下降(检查以该点为中心的3*3的范围)
for dh in range(-1, 2):
for dw in range(-1, 2):
_h = cluster.h + dh
_w = cluster.w + dw
new_gradient = self.get_gradient(_h, _w)
if new_gradient < cluster_gradient: # 找到梯度更小的
cluster.update(_h, _w, self.data[_h][_w][0], self.data[_h][_w][1],
self.data[_h][_w][2]) # 更新超像素位置
cluster_gradient = new_gradient # 暂存新的超像素

def assignment(self):
# 为每个像素分配超像素(保存在超像素中)
for cluster in self.clusters: # 遍历超像素
for h in range(cluster.h - 2 * self.S, cluster.h + 2 * self.S): # 遍历超像素周围2S大小的区域
if h < 0 or h >= self.image_height: # 超出高度范围
continue
for w in range(cluster.w - 2 * self.S, cluster.w + 2 * self.S): # 遍历超像素周围2S大小的区域
if w < 0 or w >= self.image_width: # 超出宽度范围
continue

# 计算度量
L, A, B = self.data[h][w] # 暂存当前坐标的lab值
Dc = math.sqrt( # 颜色距离
math.pow(L - cluster.l, 2) +
math.pow(A - cluster.a, 2) +
math.pow(B - cluster.b, 2))
Ds = math.sqrt( # 距离距离
math.pow(h - cluster.h, 2) +
math.pow(w - cluster.w, 2))
# 距离度量
D = math.sqrt(math.pow(Dc / self.M, 2) + math.pow(Ds / self.S, 2))

if D < self.dis[h][w]: # 找到距离更小的(2S的度量,原来不属于当前超像素的也会被算在其中)
if (h, w) not in self.label: # 当前点没有记录超像素
self.label[(h, w)] = cluster # 将当前点的超像素设置为当前超像素
cluster.pixels.append((h, w)) # 存储待更新超像素的像素点(后续需要专门的位置去进行更新)
else: # 当前点有记录超像素
self.label[(h, w)].pixels.remove((h, w)) # 从原来的点的超像素的记录中移除当前点
self.label[(h, w)] = cluster # 将当前点的超像素更新为现在的超像素
cluster.pixels.append((h, w)) # 记录当前的点
self.dis[h][w] = D

def update_cluster(self):
"""
将超像素的lab值取得属于当前超像素的所有像素的最中间的值
:return:
"""
for cluster in self.clusters: # 遍历超像素
sum_h = sum_w = number = 0
for p in cluster.pixels: # 遍历超像素的保存的内容,并且将其做平均化处理
sum_h += p[0] #
sum_w += p[1]
number += 1
_h = int(sum_h / number)
_w = int(sum_w / number)
# 将超像素的lab值取得属于当前超像素的所有像素的最中间的值
cluster.update(_h, _w, self.data[_h][_w][0], self.data[_h][_w][1], self.data[_h][_w][2])

def save_current_image(self, name):
image_arr = np.copy(self.data) # 当前图像
for cluster in self.clusters: # 遍历所有超像素
# 修改图像的lab值为其对应的超像素的lab值
for p in cluster.pixels: # 遍历每个超像素对应像素点
image_arr[p[0]][p[1]][0] = cluster.l
image_arr[p[0]][p[1]][1] = cluster.a
image_arr[p[0]][p[1]][2] = cluster.b
# 清空图像中超像素的lab值
image_arr[cluster.h][cluster.w][0] = 0
image_arr[cluster.h][cluster.w][1] = 0
image_arr[cluster.h][cluster.w][2] = 0
self.save_lab_image(name, image_arr) # 将图片存储

def iterate_10times(self, j):
self.init_clusters() # 初始化超像素
self.move_clusters()
for _ in trange(10):
self.assignment()
self.update_cluster()
name = './SLIC_result/{}.png'.format(j)
self.save_current_image(name)


if __name__ == '__main__':
for i in range(37):
print("第{}/{}个正在运行".format(i + 1, 37))
p = SLICProcessor('./result/{}.png'.format(i), 40, 40)
p.iterate_10times(i)

参考内容

SLIC超像素分割详解(一):简介: https://blog.csdn.net/electech6/article/details/45509779

超像素SLIC算法: https://www.jianshu.com/p/f2bc9dbbd9b2

SLIC算法分割超像素原理及Python实现: https://www.kawabangga.com/posts/1923

图像梯度的基本原理: https://blog.csdn.net/saltriver/article/details/78987096

ACHANTA, Radhakrishna, et al. SLIC superpixels compared to state-of-the-art superpixel methods. IEEE transactions on pattern analysis and machine intelligence, 2012, 34.11: 2274-2282.

色块提出

具体要求

2021年9月29日,教授布置了第三个课题,内容是使用超像素处理超像素化后的图片,在其中找到每个小块中颜色≥95%的最小矩形框,并且将矩形块进行输出。

这里要求输出的举行色块是超像素化后每个小块单独进行输出,输出时,该超像素所对应的最小正方形区域显示图片最初始的状态,其余区域将其颜色通道设置为0,具体表现为黑色。

这次没有参考链接,由于中间隔着一个国庆,需要去处理一些非常麻烦的事情(具体详情日记篇),所以再次认真完成的时候中间已经过去了6天

解析及想法

解析

因为是需要使用超像素化处理后的图片,因此按照正常思路来说设想是需要使用处理好的图片作为输入进行处理。

但是因为考虑到对于图片遍历需要使用到大量的循环,有可能会产生不必要的系统开销,因此最初决定对于SLIC算法的代码进行修改,在生成SLIC的同时可以保存图像色块的信息,在产生超像素图像之后,可以进一步通过保存的色块信息进行划分。

因此在代码实现上我需要面临如下几个问题

  1. 识别出每一张超像素处理后的图片的每个色块
  2. 对于每一个色块进行精准定位
  3. 按照色块对图像进行划分
  4. 找到覆盖范围≥95%的最小矩形

不过在考量处理的数据量之后,我判断循环的速度要远远快与处理SLIC,因此决定重新写一段算法来满足本次的需求。

对于上述的问题1,可以采取的方式是将图像以像素级进行遍历,找到同样颜色(颜色的RGB值相同)且相邻的所有像素进行记录,并且打上相同的标记。

这样做的好处是不需要对于SLIC算法的代码进行重构,减少修改和多余的计算时长。

因此接下来需要考虑问题4的内容,即如何能够找到覆盖范围≥95%的矩形,且要求矩形面积最小。

在画出图思考之后,我突然想到了很久之前听过的滑动窗口这一概念,这一概念最早用于网络通信的流量控制中,但是后来也可以用于解决数组、字符串的子元素等问题。

虽然在大学的课程和实践中曾经多次听过这一名词,但是我却从来没有亲自用代码实现过 ,毕竟编程的精髓在于cmd + c和cmd + v

那么既然没有代码上的经验,需要做的便是考虑如何构思这样一个算法,说干就干,于是拿出了iPad开始画图慢慢分析。

于是画出的图就成了上面这种画风了(毕竟是草稿就不要吐槽我的字丑了)

大概描述一下就是首先找到一个最小的边长,然后使用这个边长框出一个正方形在图像上滑动,每次检测该正方形框住的待求颜色的像素个数,并用该像素个数除以该颜色所在色块的所有像素值,将得到的比率与给定的比率(95%)进行比较,若不满足则移动该正方形,若移动后仍不满足则正方形边长+1重复上述步骤。

具体步骤

识别记录色块

首先需要读取图片进入内存,这里使用的是OpenCV的imread函数,因此需要转换为RGB的颜色空间。

由于需要记录图片每一个像素点的颜色信息,因此需要预先建立一个名为img_ndarry的,大小为(image_height, image_width)的二维数组,此处使用numpy进行实现,因此使用的是np.full()函数,同时使用'0000000'进行填充,经过实测,这里预先填充的字符串长度决定了该数组的可以存放的字符串长度,因此为保存颜色的RGB的HEX信息,需要提前存入长度为7的字符串(6位颜色值外加一位’#')。

但是在写下这段话的时候我突然意识到其实#是可以不进行存储的,因为没有必要,并且还可以减小空间开销。不过既然程序已经写完,就这样不进行修改了,如果小可爱看到这里的话可以尝试删掉存入程序的#号。

由于图片读入并且转换后,颜色通道为RGB,而保存在数组中的颜色通道信息希望为HEX,因此需要使用如下的方程进行转换。

1
2
3
4
5
6
7
def rgb2hex(rgb):
hex_color = '#'
for i in rgb:
num = int(i)
# 将R、G、B分别转化为16进制拼接转换并大写 hex() 函数用于将10进制整数转换成16进制,以字符串形式表示
hex_color += str(hex(num))[-2:].replace('x', '0').upper()
return hex_color

首先初始化数组,然后遍历每一个像素点,找到第一个没有标记的像素点,记录下当前像素点的颜色信息,并且将数组的对应位置记录下颜色的信息,接着在这个像素点周围开始寻找相同颜色的连通区域。

在寻找连通区域的过程中,由于其区域是不断扩大的,此时需要记录下连通区域的边界位置,即当前连通区域的上下左右的边界。

寻找边界的时候,可以使用寻找最大最小值的方式进行判定,比如将最下限设置为最高点,最上限设置为最低点,只要有新的上限大于原来的上限,则让新的上限成为上限並且保存。

决定好边界之后就要交给滑动窗口进行处理了。

处理完当前颜色后,继续处理下一个颜色。仍旧从头开始遍历每一个像素点,当当前像素点的颜色与上一个颜色不同,并且当前像素点对应位置的数组中也没有被填充颜色的时候,则代表当前像素点所对应的颜色及其连通区域尚未被遍历,因此重复寻找连通域,然后使用滑动窗口处理这一步骤。

滑动窗口方法

在使用滑动窗口方式处理时,面临的最大问题便是如何处理边界的问题。

面对边界问题,需要考虑的一共有如下的四种情况。

当方块的初始长度大边界快的长度该如何处理,如下图所示,该图像中的黄色区域占14像素,而根据d=pixel number d=\lceil {\sqrt{pixel\ number}}\ \rceil公式可以判断,该窗口的边长应为d=14 =4d=\lceil {\sqrt{14}}\ \rceil = 4

而该黄色区域的上下高度最大为3,因此可知框的窗口位置要大于其像素的连通区域,此时窗口不能够从黄色区域的最上方开始,而应该从黄色区域的最下方开始。

不过这样一来就涉及到需要比较繁杂的代码进行实现,而因为我比较懒惰,不想要进行思考而写出很多复杂的代码去解决这一问题。既然无法解决问题,就解决掉提出问题的人,也就是解决掉我自己。

既然无法决定固定大小的窗口的的起始位置,那么就直接丢掉固定窗口这一概念好了。

首先检测区域的范围与窗口初始大小的关系,如果区域足够覆盖窗口初始大小(经主观判断,大部分情况属于能够正常覆盖),则使用窗口从区域的最左上角开始滑动,找到满足条件的区域则记录面积和窗口的上下左右四个边界,并且当窗口增长到足够大并且能够遍历所有的像素点之后停止,此时找到最小的窗口并且将其对应的四个界限返回。

此时即可回到主程序进行接下来的处理。

最后的处理

根据要求,最后输出的内容需要是检测出的方块框柱的原图部分,因此需要使用如下方法进行处理。

将上一步中获取到的边界值应用于一张原图,将改变接之外的部分的颜色值变换为RGB(0, 0, 0),方块之内的部分需要保留其原本的颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def save_image(self, path, point):
"""
按照要求处理图片,然后进行保存
:param path: 图片保存路径
:param point: 上下左右的限定范围
:return:
"""
data_copy = self.source_file[point[0]:point[1], point[2]:point[3], :]

for row in range(point[0], point[1]):
for col in range(point[2], point[3]):
# print("row: {}, col: {}".format(row, col))
if self.img_ndarray[row, col] == self.last_color:
continue
else:
data_copy[row - point[0]][col - point[2]] = (0, 0, 0) # 将RGB通道设置为0

cv2.imwrite(path, data_copy[:, :, (2, 1, 0)])

如上述程序所述,首先需要根据边框画出原图的部分,然后根据img_ndarry中保存的颜色值与当前颜色值进行比较,与当前颜色符合的则保持原样,与当前颜色不同的则设置为0。

实现代码

该程序的全部代码如下所示。

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# -*- coding: UTF-8 -*-
# @Project: Progress
# @File: color 1
# @Author: rtmacha
# @Date: 2021/10/12 9:30
import math

import cv2
import numpy as np
import os


class ColorProcessor(object):

def __init__(self, file_path, source_file_path):
self.source_file = self.open_image(source_file_path)
self.data = self.open_image(file_path)
self.image_height = self.data.shape[0] # 图片高度 341
self.image_width = self.data.shape[1] # 图片宽度 266
self.img_ndarray = np.full((self.image_height, self.image_width), '0000000') # 建立一个图片大小的二维数组,用0初始化

self.last_color = "#" # 存储上一个颜色(在循环过程中为当前颜色)
self.color_set = set() # 创建一个集合用于不重复存储当前颜色的位置

# 当前颜色范围
self.most_left = self.image_width
self.most_right = 0
self.most_top = self.image_height
self.most_bottom = 0

self.color_count = 0

@staticmethod
def open_image(path):
"""
打开图片
Return:
高(row), 宽(col), 颜色([rgb])
"""
# rgb = cv2.imread(path)[:, :, (2, 1, 0)] # BGR转换为RGB(效果同下)
bgr = cv2.imread(path)
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) # BGR转换为RGB
return rgb # shape=(341, 266, 3)

@staticmethod
def rgb2hex(rgb):
hex_color = '#'
for i in rgb:
num = int(i)
# 将R、G、B分别转化为16进制拼接转换并大写 hex() 函数用于将10进制整数转换成16进制,以字符串形式表示
hex_color += str(hex(num))[-2:].replace('x', '0').upper()
return hex_color

def traversal(self, i):
"""
遍历数组
:return:
"""
counter = 0
for row in range(self.image_height):
for col in range(self.image_width):
if self.img_ndarray[row, col] != '0000000': # 当之前有过标注时
pass
else: # 当没有标注时
self.last_color = self.rgb2hex(self.data[row, col]) # 保存当前的hex颜色值
self.img_ndarray[row, col] = self.last_color # 在ndarray中标记颜色的值

self.get_connected(row, col) # 遍历找到当前点的连通区域

point = self.move_rectangle() # 上下左右

print("point: \n", point)
if point:
path = "./Process_result1/{}_{}.png".format(i, counter)
print("last_color: {}".format(self.last_color))
self.save_image(path, point)
counter += 1

def save_image(self, path, point):
"""
按照要求处理图片,然后进行保存
:param path: 图片保存路径
:param point: 上下左右的限定范围
:return:
"""
data_copy = self.source_file[point[0]:point[1], point[2]:point[3], :]

for row in range(point[0], point[1]):
for col in range(point[2], point[3]):
# print("row: {}, col: {}".format(row, col))
if self.img_ndarray[row, col] == self.last_color:
continue
else:
data_copy[row - point[0]][col - point[2]] = (0, 0, 0) # 将RGB通道设置为0

cv2.imwrite(path, data_copy[:, :, (2, 1, 0)])

def move_rectangle(self):
"""
使用矩形在范围内移动,并且逐渐扩大矩形以找到最大结果
:return: 返回 上下左右
"""
min_sqa = math.ceil(self.color_count * 0.95) # 最小像素数
print("最小像素数: ", min_sqa)
min_rec = math.ceil(math.sqrt(min_sqa)) # 最小正方形边长(颜色块数的95%)向上取整
width = self.most_right - self.most_left # 宽度
height = self.most_bottom - self.most_top # 长度
max_rec = max(width, height) # 最大正方形边长
count = 0 # 当前滑动窗口中同样颜色元素的个数
print("mo_le: {}, mo_ri: {}, mo_to: {}, mo_bo: {}".format(self.most_left, self.most_right, self.most_top, self.most_bottom))
print("min_rec: {}, max_rec: {}".format(min_rec, max_rec))
for d in range(min_rec, max_rec + 1):
print("width: {}, height: {}, d: {}".format(width, height, d))
if d > width: # 宽度不够
for y in range(height - d):
for row in range(self.most_top + y, self.most_top + y + d + 1):
for col in range(self.most_left, self.most_right + 1):
if self.img_ndarray[row, col] == self.last_color:
count += 1
if count >= min_sqa:
return [self.most_top + y, self.most_top + y + d,
self.most_left if self.most_left + d <= self.image_width else self.most_right - d,
self.most_left + d if self.most_left + d <= self.image_width else self.most_right]
elif d > height: # 高度不够
for x in range(width - d):
for row in range(self.most_top, self.most_bottom + 1):
for col in range(self.most_left + x, self.most_left + x + d + 1):
if self.img_ndarray[row, col] == self.last_color: # 当前框中颜色是当前颜色
count += 1
if count >= min_sqa:
return [self.most_top if self.most_top + d <= self.image_height else self.most_bottom - d,
self.most_top + d if self.most_top + d <= self.image_height else self.most_bottom,
self.most_left + x, self.most_left + x + d]
else:
for x in range(width - d + 1):
for y in range(height - d + 1):
for row in range(self.most_top + y, self.most_top + y + d + 1):
for col in range(self.most_left + x, self.most_left + x + d + 1):
# print("ndarry: {}, last_color: {}".format(self.img_ndarray[row, col], self.last_color))
if self.img_ndarray[row, col] == self.last_color:
count += 1
print("count: {}, min_sqa: {}".format(count, min_sqa))
if count >= min_sqa:
return [self.most_top + y, self.most_top + y + d, self.most_left + x, self.most_left + x + d]

def get_connected(self, _row, _col):
"""
在图片中从当前点寻找连通域,并且在ndarray中标注颜色
:param _row: 坐标x
:param _col: 坐标y
:return:
"""
self.color_set = set() # 创建一个集合用于不重复存储当前颜色的位置
self.color_set.add((_row, _col)) # 将当前的内容添加到set中

self.most_left = self.image_width
self.most_right = 0
self.most_top = self.image_height
self.most_bottom = 0

self.color_count = 0

while 0 != len(self.color_set):
# print("循环中, len(set): {}".format(len(self.color_set)))
[row, col] = self.color_set.pop() # 从里面随机找一个,并且删除
# print("row: {}, col: {}".format(row, col))
# print("color: {}".format(self.rgb2hex(self.data[row, col])))
self.color_count += 1 # 存储当前颜色的个数
# 依次遍历上下左右
if row - 1 >= 0:
self.set_color(row - 1, col)
if row + 1 <= self.image_height - 1:
self.set_color(row + 1, col)
if col - 1 >= 0:
self.set_color(row, col - 1)
if col + 1 <= self.image_width - 1:
self.set_color(row, col + 1)

def set_color(self, row, col):
if self.rgb2hex(self.data[row, col]) == self.last_color: # 属于当前颜色
# print("data_col: {}, last_color:{}".format(self.rgb2hex(source_file[row, col]), self.last_color))
if self.img_ndarray[row, col] != self.last_color: # 并且没有标记
# print("没有标记")
self.img_ndarray[row, col] = self.last_color
self.color_set.add((row, col))
if row > self.most_bottom:
self.most_bottom = row
if row < self.most_top:
self.most_top = row
if col < self.most_left:
self.most_left = col
if col > self.most_right:
self.most_right = col


if __name__ == '__main__':
for i in range(37):
print("第{}/{}个正在运行".format(i + 1, 37))
p = ColorProcessor('./SLIC_result/{}.png'.format(i), './result/{}.png'.format(i))
p.traversal(i)

参考内容

滑动窗口的概念: https://baike.baidu.com/item/滑动窗口

什么是「滑动窗口算法」(sliding window algorithm),有哪些应用场景?: https://www.zhihu.com/question/314669016

总结

以上便是第一次教授布置的演習課題的全部内容,虽然是分为三次进行布置的,但是综合起来我认为可以成为一个问题,因此就放在一篇文章中了。

本篇内容只是为了抹茶面对这一个课题的击破过程,如果能够为小可爱带来一丝丝灵感的话那真的是非常棒了呢。

另外:好久没有更新(水)技术性的文章了,我差点都忘记自己是计算机专业的了(笑)