EL图像检测是太阳能面板组件生产过程中必不可少的一环,以往的方式是通过肉眼观察不良情况,但生产效率较低,容易漏判或错判。这里采用数字图像处理的方式自动检测。针对这个场景,传统的图像处理泛化能力较差,调参复杂,只能处理特殊的场景,比如单晶EL,对多晶、切半、叠片等效果并不太好。而深度学习的方式可完全胜任各种场合的多种不良检测。这里仅简单介绍下传统方法的处理方式,后面有时间再介绍深度学习的方式。希望对相似场景的图像识别有启发或借鉴意义。
先来两张组件的EL图像(单晶看起来还是比较干净的),单晶的不良比较明显主要是隐裂,为了便于看出多晶的不良,这里附上了深度学习检测的多晶结果。
单晶EL(10*6规格)原始图
多晶EL(12*6规格)原始图
多晶EL(12*6)深度学习检测结果
以下将按照处理顺序进行介绍如何以传统图像处理方法识别单晶隐裂:
思路:
原始组件图直接处理不太好办,所以想把它切成电池片,针对电池片想要识别隐裂并判断其范围,我们需要一个干净的背景,即只留下隐裂,其实不同的电池片很难做到统一的效果。不难发现横向的珊线影响是比较大的,必须首先去除,然后通过二值化处理应该就差不多了,再对图像做水平和竖直的像素投影,基于像素分布规律设计合理的判断逻辑应该就能找到隐裂的起始与终止边界。
第一步:对组件切图
电池片大小相等分布均匀,最简单的思路是直接等分切割,但实际中组件最外边的黑框宽度是不确定的,对切图效果会有影响。因此,要么识别四周黑边的范围然后等分切图,要么识别每个电池片的边界切图,我这里采用第一种方式。话不多说先上代码:
class cut_img(object):
def __init__(self):
pass
def _save_max_objects(self,img):
labels = measure.label(img) # 返回打上标签的img数组
jj = measure.regionprops(labels) # 找出连通域的各种属性,注意,这里jj找出的连通域不包括背景连通域
if len(jj) == 1:
out = img
else: # 通过与质心之间的距离进行判断
num = labels.max() # 连通域的个数
del_array = np.array([0] * (num + 1)) # 生成一个与连通域个数相同的空数组来记录需要删除的区域(从0开始,所以个数要加1)
initial_area = jj[0].area
save_index = 1 # 初始保留第一个连通域
for k in range(1, num): # TODO:全黑图像,暂时不处理
if initial_area < jj[k].area:
initial_area = jj[k].area
save_index = k + 1
del_array[save_index] = 1
del_mask = del_array[labels]
out = img * del_mask
return out
def _black_edges(self,image):
height, width = image.shape[:2]
size = (int(width * 0.25), int(height * 0.25))#缩放
shrink = cv2.resize(image, size, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(shrink, cv2.COLOR_BGR2GRAY)
ret2, image_binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
ret, binary = cv2.threshold(gray, ret2 * 0.85, 255, cv2.THRESH_BINARY)#二值化
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (8, 8))#定义矩形kernel
iOpen = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
iClose = cv2.morphologyEx(iOpen, cv2.MORPH_CLOSE, kernel)#执行开闭预算去除组件内电池片珊线与边界
dst1 = self._save_max_objects(iClose)#计算符合条件的联通区
bbox = measure.regionprops(dst1)[0]['bbox']
lu = (bbox[1] * 4, bbox[0] * 4);rd = (bbox[3] * 4, bbox[2] * 4)#坐标乘以缩放值
return lu,rd
总体思路是通过二值化及开闭形态学运算形成如下图的联通区,然后提取联通区计算坐标即可。
组件黑框联通区
根据上述代码计算的坐标切出只含电池片的片区,然后根据组件规格进行等分切图。
img = Image.open(r'xxxxxxxxxxxxxxxx.jpg')
image = cv2.imread(r'xxxxxxxxxxxxxxxx.jpg')
w = 10;h=6
lu, rd = _black_edges(image)
region = (lu[0],lu[1],rd[0],rd[1])
cropImg = img.crop(region)
width, height = cropImg.size
item_width = int(width / w)
item_height = int(height / h)
for j in range(0,w):
for i in range(0,h):
box = (j*item_width,i*item_height,(j+1)*item_width,(i+1)*item_height)
imbox = cropImg.crop(box)
imbox.save('cut_test' + '/' + str(i+1)+'_'+str(j+1) + '_result.jpg',quality=95)
切出的电池片如下:
2_10_result.jpg 4_2_result.jpg效果还凑合,不影响后续操作。识别完成后,再反向拼接起来即可。下面就以4_2_result.jpg为例进行处理。
第二步:对灰度图进行腐蚀膨胀等形态学运算突出珊线
这一步主要是处理珊线,先不管隐裂,处理后,珊线越明显越好。先进行灰度化再进行腐蚀膨胀操作,这里关键是定义好kernel,因为珊线是横向的因此核的形状最好有所选择。代码如下:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
rows,cols=gray.shape
scale = 20
kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(cols//scale,1))#定义kernel
eroded = cv2.erode(gray,kernel,iterations = 3)#横向腐蚀三次
dilatedcol = cv2.dilate(eroded,kernel,iterations = 3)#横向膨胀,迭代三次,这里也可进行开闭运算,有兴趣可以试下
ret2, image_binary = cv2.threshold(dilatedcol, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)#计算合适的二值化阈值
_, binary = cv2.threshold(dilatedcol, ret2*0.85, 255, cv2.THRESH_BINARY)#效果好的话,这里应该留下清晰的珊线
cv2.imwrite('gray.jpg',gray)#灰度图
cv2.imwrite('eroded.jpg',eroded)#横向腐蚀图
cv2.imwrite('dilate.jpg',dilatedcol)#膨胀
cv2.imwrite('binary.jpg',binary)#二值化
为了清楚看到整个过程,这里保存了中间结果,如下图所示:
binary.jpg dilate.jpg eroded.jpg gray.jpg
第三步:灰度图与珊线图进行像素差运算去除主珊线
珊线突出后可以通过和原灰度图进行相减(或者逻辑与)运算进行擦除:
sub = cv2.subtract(binary,gray)
cv2.imwrite('subtract.jpg',sub)#相减效果
subtract.jpg
正常的话应该可以进行二值化了,但可以看到珊线上下还有明显的间断亮色带这会影响二值化效果,因此需要去除这些色带,腐蚀膨胀操作不够直接,这里我对珊线上下像素行赋0,相当于直接拓宽了珊线。代码如下:
def _img_mean(imgs):
'''
选取行列中0值占比超70%的行列上下拓展,类似于膨胀操作,70%为经验阈值,
大部分图片都是适用的,特殊情况可以自己调节
'''
img = imgs.copy()
height, width = img.shape[:2]
spi = [];czi = []
for y in range(0, height):
if Counter(img[y])[0]/width > 0.70:
spi.append(y)
for num in spi:#以下赋值不建议采用切片方式,否则在首尾可能报错
#对行赋0,根据效果上下宽度可调
try:
img[num] = 0
img[num+1] = 0
img[num-1] = 0
img[num+2] = 0
img[num-2] = 0
img[num+3] = 0
img[num-3] = 0
img[num+4] = 0
img[num-4] = 0
except:
pass
for x in range(0, width):
if Counter(img[:,x])[0]/width > 0.70:
czi.append(x)
for num in czi:
#对列赋0,根据效果上下宽度可调
try:
img[:,num] = 0
img[:,num+1] = 0
img[:,num-1] = 0
img[:,num+2] = 0
img[:,num-2] = 0
img[:,num+3] = 0
img[:,num-3] = 0
img[:,num-8:num] = 0
except:
pass
return img,np.mean(img)
具体扩宽的范围可以根据实际情况调整,一般稍微大一些珊线去除比较干净,太大就有可能吞噬真正的隐裂范围,需要酌情处理。
珊线拓宽后的效果
第四步:再次二值化突出隐裂
接下来就可以做二值化了,这一步选择合适的阈值,尽量只留下隐裂,背景越干净接下来投影图判断范围就更简单。关于阈值选择,这里以像素均值为参考,因为真正的隐裂明显发暗,像素值应该低于像素均值。代码如下:
r, b = cv2.threshold(subs, mean*alpha, 255, cv2.THRESH_BINARY_INV)#反二值化,阈值alpha=1.49可根据实际效果调整,最好的效果是只留下隐裂
这里的alpha是自己指定的系数我这里取1.49可以作为默认值,可直接决定二值化效果。
只保留隐裂的二值化效果.jpg
其实最右侧的间断黑边也是一种不良叫虚焊,实际场景中也需要检出,因为这里只做隐裂,因此暂时忽略其存在。
第五步:做水平和垂直的像素投影
隐裂区域像素值和背景区像素值有明显差异,表现在像素分布上就是类似于断崖式的间断区间,根据这个规律可以判断隐裂的范围。
def get_x(w,v):
incol = 1
mis = np.min(v)
result = []
try:
for i in range(0,w):
if incol==1 and abs(v[i]-mis)<=8:#根据实际效果可依照投影图czty.jpg调整这个值,这里暂时选择8
start = i#满足条件,记录起始位置
incol=0
elif incol == 0 and ((i - start) >= int(len(v)/4)) and abs(v[i]-mis)<=8 :#int(len(v)/4))是隐裂长度四分之一电池片,改值可调
end = i
result.append((start,end))
incol=1
return result[0]
except:
return 0,0
def get_y(h,z):
incol = 1
result = []
ms = np.min(z)
try:
for i in range(0,h):
if incol==1 and abs(z[i]-ms)<5:#根据实际效果可依照投影图spty.jpg调整这个值,这里暂时选择5
start = i
incol=0
elif incol == 0 and (i - start >= int(len(z)/5)) and abs(z[i]-ms)<5:
end = i
result.append((start,end))
incol=1
return result[0]
except:
return 0,0
def czty(binary):
height, width = binary.shape[:2]
v = [0]*width
a = 0
for x in range(0, width):
for y in range(0, height):
if binary[y,x] == 0:
a = a + 1
else :
continue
v[x] = a
a = 0
emptyImage = np.zeros((height, width, 3), np.uint8) #创建空白图
for x in range(0,width):
for y in range(0, v[x]):
b = (255,255,255)
emptyImage[y,x] = b
#count
x1,x2 = get_x(width,v)
cv2.imwrite('czty.jpg',emptyImage)
return x1,x2
def spty(binary):
height, width = binary.shape[:2]
a = 0;z = [0]*height
emptyImage = np.zeros((height, width, 3), np.uint8)
for y in range(0, height):
for x in range(0, width):
if binary[y,x] == 0:
a = a + 1
else :
continue
z[y] = a
a = 0
for y in range(0,height):
for x in range(0, z[y]):
b = (255,255,255)
emptyImage[y,x] = b
y1,y2 = get_y(height,z)
cv2.imwrite('spty.jpg', emptyImage)
return y1,y2
x1,x2 = self.czty(b)#计算垂直方向像素投影坐标
y1,y2 = self.spty(b)#计算水平方向像素投影坐标
投影图如下:
垂直投影 水平投影
我在像素投影图中大致标出了对应的区域,判断逻辑需要找出对应的坐标点。
水平投影需对像素矩阵进行纵向遍历,首先定义一个起始点记录标志incol = 1,同样是以整个图像的像素均值ms为参考(注意这里所说的像素是像素值为0的个数),比较该行像素与ms的大小,这里选的阈值为5,低于这个值我们认为已进入隐裂范围。记录这个起始点后要把incol置0,因为后面有大部分满足该条件的点,接下来就开始判断终点;这里假设隐裂长度都不小于1/5的电池片宽度,低于此值的不做终点判断。当某行像素达到条件后,即认为找到终点,然后记录开始和结束位置,因为可能有多个隐裂出现,把位置元组加进一个list里,简单起见,这里只返回了第一个。垂直投影的判断逻辑类似这里不赘述了。计算坐标后画出隐裂位置:
s = cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
cv2.imwrite('result.jpg',s)
下面给出判断效果
result.jpg
完整代码如下:
# =============================================================================
# 切图类,返回切图坐标
# =============================================================================
class cut_img(object):
def __init__(self):
pass
def _save_max_objects(self,img):
labels = measure.label(img) # 返回打上标签的img数组
jj = measure.regionprops(labels) # 找出连通域的各种属性,注意,这里jj找出的连通域不包括背景连通域
if len(jj) == 1:
out = img
else: # 通过与质心之间的距离进行判断
num = labels.max() # 连通域的个数
del_array = np.array([0] * (num + 1)) # 生成一个与连通域个数相同的空数组来记录需要删除的区域(从0开始,所以个数要加1)
initial_area = jj[0].area
save_index = 1 # 初始保留第一个连通域
for k in range(1, num): # TODO:全黑图像,暂时不处理
if initial_area < jj[k].area:
initial_area = jj[k].area
save_index = k + 1
del_array[save_index] = 1
del_mask = del_array[labels]
out = img * del_mask
return out
def _black_edges(self,image):
height, width = image.shape[:2]
size = (int(width * 0.25), int(height * 0.25))#缩放
shrink = cv2.resize(image, size, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(shrink, cv2.COLOR_BGR2GRAY)
ret2, image_binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
ret, binary = cv2.threshold(gray, ret2 * 0.85, 255, cv2.THRESH_BINARY)#二值化
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (8, 8))#定义矩形kernel
iOpen = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
iClose = cv2.morphologyEx(iOpen, cv2.MORPH_CLOSE, kernel)#执行开闭预算去除组件内电池片珊线与边界
dst1 = self._save_max_objects(iClose)#计算符合条件的联通区
bbox = measure.regionprops(dst1)[0]['bbox']
lu = (bbox[1] * 4, bbox[0] * 4);rd = (bbox[3] * 4, bbox[2] * 4)#坐标乘以缩放值
return lu,rd
class alg_cell():
def __init__(self,img,alfa=1.41,save_temp_img=True):
self.save_temp_img = save_temp_img
self.alfa = alfa
self.img = img
self.gray = cv2.cvtColor(self.img, cv2.COLOR_BGR2GRAY)
self.rows,self.cols=self.gray.shape
self.scale = 20
self.kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(self.cols//self.scale,1))#定义kernel
self.eroded = cv2.erode(self.gray,self.kernel,iterations = 3)#横向腐蚀三次
self.dilatedcol = cv2.dilate(self.eroded,self.kernel,iterations = 3)#横向膨胀,迭代三次,这里也可进行开闭运算,有兴趣可以试下
self.ret2, image_binary = cv2.threshold(self.dilatedcol, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)#计算合适的二值化阈值
_, self.binary = cv2.threshold(self.dilatedcol, self.ret2*0.85, 255, cv2.THRESH_BINARY)#效果好的话,这里应该留下清晰的珊线
if self.save_temp_img:
cv2.imwrite('gray.jpg',self.gray)#灰度图
cv2.imwrite('eroded.jpg',self.eroded)#横向腐蚀图
cv2.imwrite('dilate.jpg',self.dilatedcol)#膨胀
cv2.imwrite('binary.jpg',self.binary)#二值化
def _img_mean(self,imgs):
'''
选取行列中0值占比超70%的行列上下拓展,类似于膨胀操作,70%为经验阈值,
大部分图片都是适用的,特殊情况可以自己调节
'''
img = imgs.copy()
height, width = img.shape[:2]
spi = [];czi = []
for y in range(0, height):
if Counter(img[y])[0]/width > 0.70:
spi.append(y)
for num in spi:#以下赋值不建议采用切片方式,否则在首尾可能报错
#对行赋0,根据效果上下宽度可调
try:
img[num] = 0
img[num+1] = 0
img[num-1] = 0
img[num+2] = 0
img[num-2] = 0
img[num+3] = 0
img[num-3] = 0
img[num+4] = 0
img[num-4] = 0
except:
pass
for x in range(0, width):
if Counter(img[:,x])[0]/width > 0.70:
czi.append(x)
for num in czi:
#对列赋0,根据效果上下宽度可调
try:
img[:,num] = 0
img[:,num+1] = 0
img[:,num-1] = 0
img[:,num+2] = 0
img[:,num-2] = 0
img[:,num+3] = 0
img[:,num-3] = 0
img[:,num-8:num] = 0
except:
pass
return img,np.mean(img)
def get_x(self,w,v):
incol = 1
mis = np.min(v)
result = []
try:
for i in range(0,w):
if incol==1 and abs(v[i]-mis)<=8:#根据实际效果可依照投影图czty.jpg调整这个值,这里暂时选择8
start = i#满足条件,记录起始位置
incol=0
elif incol == 0 and ((i - start) >= int(len(v)/4)) and abs(v[i]-mis)<=8 :#int(len(v)/4))是隐裂长度四分之一电池片,改值可调
end = i
result.append((start,end))
incol=1
return result[0]
except:
return 0,0
def get_y(self,h,z):
incol = 1
result = []
ms = np.min(z)
try:
for i in range(0,h):
if incol==1 and abs(z[i]-ms)<5:#根据实际效果可依照投影图spty.jpg调整这个值,这里暂时选择5
start = i
incol=0
elif incol == 0 and (i - start >= int(len(z)/5)) and abs(z[i]-ms)<5:
end = i
result.append((start,end))
incol=1
return result[0]
except:
return 0,0
def czty(self,binary):
height, width = binary.shape[:2]
v = [0]*width
a = 0
for x in range(0, width):
for y in range(0, height):
if binary[y,x] == 0:
a = a + 1
else :
continue
v[x] = a
a = 0
emptyImage = np.zeros((height, width, 3), np.uint8) #创建空白图
for x in range(0,width):
for y in range(0, v[x]):
b = (255,255,255)
emptyImage[y,x] = b
#count
x1,x2 = self.get_x(width,v)
if self.save_temp_img:
cv2.imwrite('czty.jpg',emptyImage)
return x1,x2
def spty(self,binary):
height, width = binary.shape[:2]
a = 0;z = [0]*height
emptyImage = np.zeros((height, width, 3), np.uint8)
for y in range(0, height):
for x in range(0, width):
if binary[y,x] == 0:
a = a + 1
else :
continue
z[y] = a
a = 0
for y in range(0,height):
for x in range(0, z[y]):
b = (255,255,255)
emptyImage[y,x] = b
y1,y2 = self.get_y(height,z)
if self.save_temp_img:
cv2.imwrite('spty.jpg', emptyImage)
return y1,y2
def start(self):
'''
第一步:对灰度图进行腐蚀膨胀等形态学运算突出珊线
第二步:进行合理的二值化,留下清晰的珊线图,这两步均在类初始化函数__init__完成
第三步:灰度图与珊线图进行像素差运算(逻辑与也可以),去除主珊线
第四步:设计算法,对沿主珊线上下的不连续焊条带进行擦除
第五步:进行反二值化,尽量只留下隐裂
第六步:做水平和垂直方向的像素投影
第七步:设计算法,计算水平和竖直方向的长度坐标,最终效果需要精调这里
'''
sub = cv2.subtract(self.binary,self.gray)#相减消除主珊线
subs,mean = self._img_mean(sub)#沿主珊线擦除不连续焊带
r, b = cv2.threshold(subs, mean*self.alfa, 255, cv2.THRESH_BINARY_INV)#反二值化,阈值1.41可根据实际效果调整,最好的效果是只留下隐裂
x1,x2 = self.czty(b)#计算垂直方向像素投影坐标
y1,y2 = self.spty(b)#计算水平方向像素投影坐标
if sum([x1,x2,y1,y2])==0:
print('Normal!')
else:
s = cv2.rectangle(self.img, (x1, y1), (x2, y2), (0, 0, 255), 2)
cv2.imwrite('result.jpg',s)
if self.save_temp_img:
cv2.imwrite('subtract.jpg',sub)#相减效果
cv2.imwrite('subs_mean.jpg',subs)#去除珊线效果
cv2.imwrite('subs_mean_b.jpg',b)#除珊线的二值化
# =============================================================================
# 测试,这里为测试方便没有给路径,默认图片读取和保存均在代码根目录进行
# =============================================================================
if __name__ == '__main__':
# =============================================================================
# 切图
# =============================================================================
# img = Image.open(r'xxxxxx.jpg')
# image = cv2.imread('xxxxxxx.jpg')
# w = 10;h=6
# lu, rd = _black_edges(image)
# region = (lu[0],lu[1],rd[0],rd[1])
# cropImg = img.crop(region)
# width, height = cropImg.size
# item_width = int(width / w)
# item_height = int(height / h)
# for j in range(0,w):
# for i in range(0,h):
# box = (j*item_width,i*item_height,(j+1)*item_width,(i+1)*item_height)
# imbox = cropImg.crop(box)
# imbox.save('cut_test' + '/' + str(i+1)+'_'+str(j+1) + '_result.jpg',quality=95)
# =============================================================================
# 隐裂检测
# =============================================================================
image = cv2.imread(r'4_2_result.jpg')
test = alg_cell(image,alfa=1.49)
test.start()
小结
其实传统方法识别隐裂已经比较吃力,主要的问题在于实际图像情况复杂,一套参数很难通用,导致方法的泛化性能很差,难以推广。深度学习则不同,经过大量的图片训练,在多种图像场景下都能工作的很好,且能识别多种不良。其庞大的参数规模使其具有很好的泛化性和抗干扰能力。下面贴两张深度学习的端到端识别多晶组件不良的效果图(深度学习识别单晶的效果远好于多晶),以后有时间再具体介绍。
组件多晶12*6 组件多晶12*6
网友评论