项目任务:能够识别出银行卡号,并且在原图片上标识出银行卡号的位置
银行卡示例:
模板示例:
整体思路:先解决模板,将每一个数字设置成一个小模板储存在字典里,其中每个数字对应着它的小模板的具体值(矩阵)。再解决银行卡图片,想办法将复杂的图片简化,突出卡号数字的部分,然后将银行卡模糊化,使得四块数字部分分别成为一个整体。接着对图片上所有轮廓求外接矩形,通过外接矩形(x,y,w,h)参数之间的关系准确识别出卡号数字部分,将这几个部分取出来存放在一个list中。然后分别对这四个部分进行模式匹配,得到具体的卡号,再组合成为完整的银行卡号。
代码简析:
1. 导入模块,分别为numpy、cv2、argparse、myutils
其中myutils为自己设定的文件,用于存放这个程序中所有的自定义函数,这个文件在文后会进行解析。
import numpy as np
import cv2
import argparse # 用于添加参数
import myutils as ms # 自己设定的文件
2. 这里使用命令行参数进行设置,设置了两个参数:image、template,这两个参数分别为银行卡图片以及模板图片。这里也可以不使用命令行参数,用cv2.imread导入图像文件不影响最后的运行结果,且命令行参数和cv2.imread的区别在这个项目中无法体现。
银行卡参数指的是银行卡号首位数,能够表示此银行卡的信息,将其存入一个字典中,在卡号识别完成后可以加一条关于此银行卡的信息。
# 设置参数
# 用命令行参数进行设置,方法可以理解,但是不明白为什么要使用命令行而不是直接读取图片
ap = argparse.ArgumentParser()
# 需要匹配的银行卡图片
ap.add_argument('-i', '--image', required=True, help='a path to input images')
# 模板
ap.add_argument('-t', '--template', required=True, help='a path to template OCR-A image')
# ap.parse_args()可以用来解析参数,解析之后才能使用
args = vars(ap.parse_args()) # 将命令行参数解析器(ArgumentParser)解析的结果存储在字典中
# 银行卡信息
# 用字典来设置银行卡首字母的信息,增加信息维度
first_number = {
'3': 'American Express',
'4': 'Viso',
'5': 'MasterCard',
'6': 'Discover Card'
}
3. 做一些关于模板的准备工作:转灰度图、二值化。其中ms.cv_show(name, img)为myutils文件中的函数,其作用为展示图片,相关代码在文后解析。其中需要注意的是,cv2.threshold得到的结果是一个list,第一个是阈值,第二个是图片,所以后面跟着[1]直接代表list中第二个参数。
这里阈值设定为12,因为模板本身就是仅有黑白两色,阈值的设定只要不过于接近边界都是可以的,如果不想要自己设定,那么使用(cv2.THRESH_BINARY | cv2.THRESH_OTSU)也是可以的,它的具体用法在下文也有讲解。
# 展示一下模板图片:
img_temp = cv2.imread(args['template'])
ms.cv_show('temp', img_temp)
# 转换成灰度图:
ref = cv2.cvtColor(img_temp, cv2.COLOR_BGR2GRAY)
ms.cv_show('ref', ref)
# 二值化:
# cv2.threshold得到的结果是一个list,第一个是阈值,第二个是图片,所以后面跟着[1]直接代表list中第二个参数
# list中可以放不同的数据类型
ref = cv2.threshold(ref, 12, 255, cv2.THRESH_BINARY_INV)[1]
ms.cv_show('ref', ref)
4. 绘制模板的轮廓,为设置匹配模板做准备。这里使用了cv2.findContours()函数。
这一段最后的print函数是为了验证轮廓是否为十个,直接refcnts.shape会出现错误'tuple' object has no attribute 'shape',因此需要用np.array函数将它变成列表然后再用.shape来求轮廓数量。
# 绘制轮廓
# cv2.findContours()读取的是二值图,不是灰度图,cv2.RETR_EXTERNAL只检测外轮廓,cv2.CHAIN_APPROX_SIMPLE只保留终点坐标
# 函数返回的是一个list,其中每个元素都是一个轮廓
# 函数详解:https://blog.csdn.net/weixin_43869605/article/details/119921444
# cv2.drawContours在以上网站亦有具体讲解
refCnts, hierarchy = cv2.findContours(ref.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(img_temp, refCnts, -1, (0, 255, 0), 2)
ms.cv_show('img', img_temp)
Data_type = list # 为了验证轮廓数是否正确,给refCnts强行定义一个类型,解决Value error
print(np.array(refCnts, dtype=Data_type).shape)
5. 将以上得到的轮廓进行排序,把排序好的数字的外接矩形图片存入一个字典(temple_final)中,这样就能得到一个存放各个小模板的一个字典,它的键就是卡号,值就是卡号对应数字的外接矩形图片,再将得到的各个矩形图片全部设置成为一个固定的图片比例以方便之后的匹配操作,我这里设置的是(60,90),以矩阵的形式存放在字典中。至此对模板的操作告一段落。
这里用到了一个函数ms.sort_contours(cnts, method='left-to-right'),它是myutils文件中的一个函数,它的作用是计算轮廓的外接矩形,然后按照给定要求(比如'left-to-right':从左到右)对所有矩形进行排序,输入的是轮廓集合(cnts)以及排序方法(method='left-to-right'),输出的是排序好的轮廓以及排序好的外接矩形参数(x,y,w,h)。
# 把轮廓放到字典中
refCnts_final = ms.sort_contours(refCnts, method='left-to-right')[0]
bounding_boxes = ms.sort_contours(refCnts, method='left-to-right')[1] # 排序
temple_final = {} # 设一个空字典用于存放模板
for (i, c) in enumerate(refCnts_final): # 实际存放
# enumerate():枚举操作,i代表第几个,c代表实际的参数
(x, y, w, h) = cv2.boundingRect(c)
roi = ref[y:y+h, x:x+w]
roi = cv2.resize(roi, (60, 90))
temple_final[i] = roi # 对应模板
6. 开始对银行卡图片进行操作。代码的一开始定义了四个卷积核,其中rectKernel和sqKernel是此项目中绝大部分人使用的卷积核,我这里用在了礼帽操作中,后两个卷积核是我自己经过调试找到的适合于我自己对图像操作的卷积核。
首先是读取银行卡图像并且将其转成灰度图,然后做图像操作。为了简化图片上的信息,可以在灰度图上进行各种图像操作,以达到简化的目的,这一部分的操作并不要求保持图片的清晰,反而是需要想办法尽量将四块的卡号数字糊成一片,方便做外接矩形。在对图片简化的操作上,按照其他人完成此项目的方法,是先进行礼帽操作,再使用sobel算子进行梯度操作,然后再作闭操作,最后二值化得到可用的模糊图。我的方法是先进行礼帽操作(这一步是试验了开闭操作、膨胀腐蚀操作等之后,找到的最佳方法),然后直接进行二值化(这里的阈值设置也是在经过实验之后得到的,使用的银行卡图片一共有五张,其中第五张在底层有暗色的横纹,这一步就是去掉它横纹的重要一步,但是这个阈值又不可以设置地太大,如果太大的话会抹掉部分数字的边界,那么某一个或几个数字块就会出现断裂,无法形成一个整体。),接着使用sobel算子做梯度计算(在这里只选择了x方向的梯度,因为y方向的梯度会损失掉大部分的数字信息,而x和y两个梯度加起来虽然数字更加清楚,但是在将数字做成整块的时候,效果反而不好),最后用两个大的卷积核进行了两次闭操作,这样能够使这个二值图上的数字信息全部模糊成一个整块。
然后就是对这个模糊的二值图进行轮廓绘制和求外接矩形,再用一个for循环判断轮廓是否为数字信息。这里判断卡号数字矩形的规则也是在集合了五张银行卡样本所得参数之后,对各个外接矩形参数之间关系进行了总结,计算得出的判断标准。这个标准仅使用了比值关系而没有选择用参数范围进行限制,相对于其他人的方法来说,能够多一些普适性。
最后将得到的四个外接矩形,按照从左到右的顺序保存在locs中。
# 开始对银行卡进行操作
# 先设定卷积核
rectKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 3)) # 全为1,和直接np.ones应该是差不多
sqKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
Kernel_1 = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 3)) # 最终发现在二值图下,这个卷积核效果最好
Kernel_2 = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
# 读取银行卡图像
img_card = cv2.imread(args['image'])
ms.cv_show('card', img_card)
# 转成灰度图
gray_card = cv2.cvtColor(img_card, cv2.COLOR_BGR2GRAY)
ms.cv_show('gray_card', gray_card)
# 尝试简化图片,留下醒目信息
# 礼帽操作效果最明显,留下了高亮区域:
card_TOP = cv2.morphologyEx(gray_card.copy(), cv2.MORPH_TOPHAT, rectKernel)
ms.cv_show('TOP', card_TOP)
# 做一下二值化去掉一些暗点
card_TOP = cv2.threshold(card_TOP, 70, 255, cv2.THRESH_BINARY)[1]
ms.cv_show('TOP2', card_TOP)
# 用sobel算子:
card_sobel_x = cv2.Sobel(card_TOP, -1, 1, 0, ksize=5)
ms.cv_show('sobelx', card_sobel_x)
# 发现x,y加上之后,虽然轮廓更清晰,但是无法成块,因此只用x
# card_sobel_y = cv2.Sobel(card_TOP, -1, 0, 1, ksize=3)
# ms.cv_show('sobely', card_sobel_y)
# card_sobel = cv2.add(card_sobel_x, card_sobel_y)
card_sobel = card_sobel_x
ms.cv_show('sobel', card_sobel)
# 闭操作(大卷积核)使其成块:
card_close = cv2.morphologyEx(card_sobel, cv2.MORPH_CLOSE, rectKernel)
# ms.cv_show('card_close1', card_close)
# card_close = cv2.morphologyEx(card_close, cv2.MORPH_CLOSE, sqKernel)
# ms.cv_show('card_close2', card_close)
card_close = cv2.morphologyEx(card_close, cv2.MORPH_CLOSE, Kernel_1)
ms.cv_show('card_close', card_close)
# card_close = cv2.morphologyEx(card_close, cv2.MORPH_ERODE, Kernel_2)
# ms.cv_show('card_close4', card_close)
card_cnts, hierarchy = cv2.findContours(card_close.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
img_card_copy = img_card.copy()
cv2.drawContours(img_card_copy, card_cnts, -1, (0, 255, 0), 2)
ms.cv_show('close', img_card_copy)
# 求外接矩形,并且用比例来判断是否为卡号数字
locs = []
for (i, c) in enumerate(card_cnts):
(x, y, w, h) = cv2.boundingRect(c)
ar = w / float(h)
ap = y / float(w)
# 经过各种尝试以及计算,得到了如下的范围取值
if 2.55 < ar < 3.3 and 2.0 < ap < 2.5:
locs.append((x, y, w, h))
locs = sorted(locs, key=lambda b: b[0])
print(locs)
7. 准备进行模板匹配。设置一个output列表,作为最后银行卡号的结果。模板匹配通过嵌套三个for循环完成。将locs中存入的四个卡号数字块依次放入第一个循环中,在循环中设置一个groupOutput参数用来存放每个块的 卡号数字,接着对这个块进行二值化、绘制边界、绘制外接矩形以及排序。将得到的排列好的边界放入第二个循环中,计算外接矩形并提出矩形图片然后统一为标准尺寸得到roi。将roi放入第三个循环中进行模式匹配,将匹配得到最高分数的结果保存下来,放入groupOutput中。第二、第三个循环结束后,在银行卡原图上绘制每个数字块的框图,把所有的卡号存入output中。
# 进行模板匹配
output = []
for (i, (X, Y, W, H)) in enumerate(locs):
# 创建一个空list,存储卡号数值
groupOutput = []
group = gray_card[Y-5: Y+H+5, X-5: X+W+5]
# ms.cv_show('group', group)
group = cv2.morphologyEx(group, cv2.MORPH_CLOSE, Kernel_2)
# 二值化group
group = cv2.threshold(group, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
ms.cv_show('group', group)
# 计算轮廓
group_cnts, hierarchy = cv2.findContours(group.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
group_cnts, bounding_boxes_digit = ms.sort_contours(group_cnts, method='left-to-right')
print(bounding_boxes_digit)
# 对每个数字进行模板匹配
for c in group_cnts:
(x, y, w, h) = cv2.boundingRect(c)
roi = group[y: y+h, x: x+w]
roi = cv2.resize(roi, (60, 90))
# ms.cv_show('roi', roi)
# 得分list
scores = []
# 对当前数字与各个模板进行匹配
for (digit, digit_temp) in temple_final.items():
result = cv2.matchTemplate(roi, digit_temp, cv2.TM_CCOEFF)
# min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(ret)
# min_val, max_val, min_loc, max_loc 分别表示最小值,最大值,以及对应的位置, ret输入的矩阵
(_, score, _, _) = cv2.minMaxLoc(result)
scores.append(score)
# 找到最大的数值,即为匹配结果
groupOutput.append(str(np.argmax(scores)))
cv2.rectangle(img_card, (X-5, Y-5), (X+W+5, Y+H+5), (0, 255, 0), 1) # 在原图上画出来对应位置
output.extend(groupOutput)
8. 打印银行卡属性和卡号,以及展示最终被框出卡号数字的银行卡图片。
print('银行卡类型为:{}'.format(first_number[output[0]]))
print('银行卡号为:{}'.format(''.join(output)))
ms.cv_show('card', img_card)
以下为总的代码:
import numpy as np
import cv2
import argparse # 用于添加参数
import myutils as ms # 自己设定的文件
# 设置参数
# 用命令行参数进行设置,方法可以理解,但是不明白为什么要使用命令行而不是直接读取图片
ap = argparse.ArgumentParser()
ap.add_argument('-i', '--image', required=True, help='a path to input images') # 需要匹配的银行卡图片
ap.add_argument('-t', '--template', required=True, help='a path to template OCR-A image') # 模板
# ap.parse_args()可以用来解析参数,解析之后才能使用
args = vars(ap.parse_args()) # 将命令行参数解析器(ArgumentParser)解析的结果存储在字典中
# 银行卡信息
# 用字典来设置银行卡首字母的信息,增加信息维度
first_number = {
'3': 'American Express',
'4': 'Viso',
'5': 'MasterCard',
'6': 'Discover Card'
}
# 展示一下模板图片:
img_temp = cv2.imread(args['template'])
ms.cv_show('temp', img_temp)
# 转换成灰度图:
ref = cv2.cvtColor(img_temp, cv2.COLOR_BGR2GRAY)
ms.cv_show('ref', ref)
# 二值化:
# cv2.threshold得到的结果是一个list,第一个是阈值,第二个是图片,所以后面跟着[1]直接代表list中第二个参数
# list中可以放不同的数据类型
ref = cv2.threshold(ref, 12, 255, cv2.THRESH_BINARY_INV)[1]
ms.cv_show('ref', ref)
# 绘制轮廓
# cv2.findContours()读取的是二值图,不是灰度图,cv2.RETR_EXTERNAL只检测外轮廓,cv2.CHAIN_APPROX_SIMPLE只保留终点坐标
# 函数返回的是一个list,其中每个元素都是一个轮廓
# 函数详解:https://blog.csdn.net/weixin_43869605/article/details/119921444
# cv2.drawContours在以上网站亦有具体讲解
refCnts, hierarchy = cv2.findContours(ref.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(img_temp, refCnts, -1, (0, 255, 0), 2)
ms.cv_show('img', img_temp)
Data_type = list # 为了验证轮廓数是否正确,给refCnts强行定义一个类型,解决Value error
print(np.array(refCnts, dtype=Data_type).shape)
# 把轮廓放到字典中
refCnts_final = ms.sort_contours(refCnts, method='left-to-right')[0]
bounding_boxes = ms.sort_contours(refCnts, method='left-to-right')[1] # 排序
temple_final = {} # 设一个空字典用于存放模板
for (i, c) in enumerate(refCnts_final): # 实际存放
# enumerate():枚举操作,i代表第几个,c代表实际的参数
(x, y, w, h) = cv2.boundingRect(c)
roi = ref[y:y+h, x:x+w]
roi = cv2.resize(roi, (60, 90))
temple_final[i] = roi # 对应模板
# 开始对银行卡进行操作
# 先设定卷积核
rectKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 3)) # 全为1,和直接np.ones应该是差不多
sqKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
Kernel_1 = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 3)) # 最终发现在二值图下,这个卷积核效果最好
Kernel_2 = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
# 读取银行卡图像
img_card = cv2.imread(args['image'])
ms.cv_show('card', img_card)
# 转成灰度图
gray_card = cv2.cvtColor(img_card, cv2.COLOR_BGR2GRAY)
ms.cv_show('gray_card', gray_card)
# 尝试简化图片,留下醒目信息
# 礼帽操作效果最明显,留下了高亮区域:
card_TOP = cv2.morphologyEx(gray_card.copy(), cv2.MORPH_TOPHAT, rectKernel)
ms.cv_show('TOP', card_TOP)
# 做一下二值化去掉一些暗点
card_TOP = cv2.threshold(card_TOP, 70, 255, cv2.THRESH_BINARY)[1]
ms.cv_show('TOP2', card_TOP)
# 用sobel算子:
card_sobel_x = cv2.Sobel(card_TOP, -1, 1, 0, ksize=5)
ms.cv_show('sobelx', card_sobel_x)
# 发现x,y加上之后,虽然轮廓更清晰,但是无法成块,因此只用x
# card_sobel_y = cv2.Sobel(card_TOP, -1, 0, 1, ksize=3)
# ms.cv_show('sobely', card_sobel_y)
# card_sobel = cv2.add(card_sobel_x, card_sobel_y)
card_sobel = card_sobel_x
ms.cv_show('sobel', card_sobel)
# 闭操作(大卷积核)使其成块:
card_close = cv2.morphologyEx(card_sobel, cv2.MORPH_CLOSE, rectKernel)
# ms.cv_show('card_close1', card_close)
# card_close = cv2.morphologyEx(card_close, cv2.MORPH_CLOSE, sqKernel)
# ms.cv_show('card_close2', card_close)
card_close = cv2.morphologyEx(card_close, cv2.MORPH_CLOSE, Kernel_1)
ms.cv_show('card_close', card_close)
# card_close = cv2.morphologyEx(card_close, cv2.MORPH_ERODE, Kernel_2)
# ms.cv_show('card_close4', card_close)
card_cnts, hierarchy = cv2.findContours(card_close.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
img_card_copy = img_card.copy()
cv2.drawContours(img_card_copy, card_cnts, -1, (0, 255, 0), 2)
ms.cv_show('close', img_card_copy)
# 求外接矩形,并且用比例来判断是否为卡号数字
locs = []
for (i, c) in enumerate(card_cnts):
(x, y, w, h) = cv2.boundingRect(c)
ar = w / float(h)
ap = y / float(w)
# 经过各种尝试以及计算,得到了如下的范围取值
if 2.55 < ar < 3.3 and 2.0 < ap < 2.5:
locs.append((x, y, w, h))
locs = sorted(locs, key=lambda b: b[0])
print(locs)
# 进行模板匹配
output = []
for (i, (X, Y, W, H)) in enumerate(locs):
# 创建一个空list,存储卡号数值
groupOutput = []
group = gray_card[Y-5: Y+H+5, X-5: X+W+5]
# ms.cv_show('group', group)
group = cv2.morphologyEx(group, cv2.MORPH_CLOSE, Kernel_2)
# 二值化group
group = cv2.threshold(group, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
ms.cv_show('group', group)
# 计算轮廓
group_cnts, hierarchy = cv2.findContours(group.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
group_cnts, bounding_boxes_digit = ms.sort_contours(group_cnts, method='left-to-right')
print(bounding_boxes_digit)
# 对每个数字进行模板匹配
for c in group_cnts:
(x, y, w, h) = cv2.boundingRect(c)
roi = group[y: y+h, x: x+w]
roi = cv2.resize(roi, (60, 90))
# ms.cv_show('roi', roi)
# 得分list
scores = []
# 对当前数字与各个模板进行匹配
for (digit, digit_temp) in temple_final.items():
result = cv2.matchTemplate(roi, digit_temp, cv2.TM_CCOEFF)
# min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(ret)
# min_val, max_val, min_loc, max_loc 分别表示最小值,最大值,以及对应的位置, ret输入的矩阵
(_, score, _, _) = cv2.minMaxLoc(result)
scores.append(score)
# 找到最大的数值,即为匹配结果
groupOutput.append(str(np.argmax(scores)))
cv2.rectangle(img_card, (X-5, Y-5), (X+W+5, Y+H+5), (0, 255, 0), 1) # 在原图上画出来对应位置
output.extend(groupOutput)
print('银行卡类型为:{}'.format(first_number[output[0]]))
print('银行卡号为:{}'.format(''.join(output)))
ms.cv_show('card', img_card)
myutils文件:
文件中共有三个函数,第一个函数是一个读取中文文件夹的cv_imread函数。第二个是一个非常简单的展示图片的cv_show函数。第三个函数是一个排序函数,最核心的就是压缩以及解压的那一句代码,在注释中已经明确解释了。
import numpy as np
import cv2
# 读取中文文件夹
def cv_imread(path):
img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR)
return img
# 设置一个展示图片的函数cvShow
# 以下为一个不成功的想法:
# def cv_show(img):
# for name, value in globals().items():
# if value is img:
# img_name = name
# cv2.imshow(img_name, img)
# cv2.waitKey(0)
# cv2.destroyAllWindows()
# break
# 正常的想法:
def cv_show(name, img):
cv2.imshow(name, img)
cv2.waitKey(0)
cv2.destroyAllWindows()
# 给轮廓进行排序:
def sort_contours(cnts, method='left-to-right'): # 默认是左到右正序排列
reverse = False # 在zip里面表示正序排序
i = 0 # 表示的是x参数,也就是横坐标参数,bounding_boxes里面的是(x,y,h,w),x为左上角横坐标,y为左上角纵坐标
if method == 'right-to-left' or method == 'bottom-to-top': #
reverse = True # 在zip里面表示倒序排序
if method == 'tpo-to-bottom' or method == 'bottom-to-top':
i = 1 # 表示的纵坐标参数
# 画出边界矩形,找到矩形左上角坐标并且对应于轮廓
# cv2.boundingRect(c)得到的结果为四个值:x,y,h,w
bounding_boxes = [cv2.boundingRect(c) for c in cnts] # 结果是一个list,其中每个元素都为tuple
# zip():压缩,将各个参数中对应的元素打包成一个元组作为新的参数(多个参数以列为单位排放,再横着组成元组)
# zip(*zip()):解压,将压缩包中的元组的每个部分拆散,重新将各个位置组合成一个新的元组(横着的元组组成一列,再将每一列分开)
# zip: https://blog.csdn.net/Hu_Linson/article/details/121106294
# sorted(iterable,key=None, reverse=False):对所有可迭代的对象进行排序操作。
# sorted: https://blog.csdn.net/liujingwei8610/article/details/121299626
# 其中iterable为可迭代对象,key为排序依据,reverse为排序方式,False为正序,True为倒序
# lambda为匿名函数,lambda b: b[1][i]的含义是b的第二个参数的第i+1个参数,也就是以这项参数的大小的排序依据
# lamda: https://blog.csdn.net/m0_70952270/article/details/126910847
(cnts, bounding_boxes) = zip(*sorted(zip(cnts, bounding_boxes), key=lambda b: b[1][i], reverse=reverse))
return cnts, bounding_boxes