最近在做一个项目,需要在拍摄物体对象中同时拍摄色卡作为颜色参照,并根据色卡对照片进行校色。为了在照片中找出色卡并将照片中实际拍摄的色卡与标准色卡颜色进行对比/校色,需要将色卡的完整提取出来,并对提取的色卡轮廓进行透视变换为色卡的正视图,然后将变换后的色卡和标准色卡的颜色进行对比。
首先想到的思路是使用霍夫变换,检测出色卡的四条直线边缘,计算四条边缘的焦点获取色卡四个角点。但可能是因为自己对 OpenCV 完全不熟悉,不论我怎么调整参数都没法很好地将色卡的四条边缘完整提取出来。另外也很可能是因为色卡本身色块格子较多,色卡内部的直线也很难滤除。
之后也想到了使用轮廓检测的方式,获取色卡的外围轮廓,但考虑到我们的实际使用场景,可能需要手持色卡,色卡的边缘可能会被手遮挡,不能获取完整色卡边缘,因此也没法采用。
定位点的设计与二维码定位相同,如下图所示,由一个正方形和一个方形环构成。同时外围方形环的宽度、中间的正方形边长以及两者之间的留空保持一定的比例。二维码的检测与这个比例关系相关,但本文的检测方法与比例无关,更多的是考虑轮廓的层级关系,因此实际上定位点设计使用任意比例只要保证形状类似就可以。
虽然其实三个定位点就可以估算第四个点的位置,但考虑到技术有 (tai) 限 (cai),多印一个定位点总比多写几行计算代码容易,所以在色卡四角均放置了一个定位点。最终色卡的设计是这样的:
色卡的定制和印刷不是本文的内容,因此直接从检测定位点开始说起。
首先将图像转换为灰度,再对其进行二值化,方便寻找轮廓。之后对二值图像进行高斯滤波,并使用 Canny 找出边缘。
gray = cv2.cvtColor(img.copy(), cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(
gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
blur = cv2.GaussianBlur(binary, (5, 5), 0)
edges = cv2.Canny(blur, 100, 300)
找到边缘后使用 cv2.findContours 方法找到寻找图像中的所有轮廓。findContours 方法有三个参数和返回值,第一个参数是寻找轮廓的图像;第二个是轮廓提取的方式,在本文中由于需要得到各轮廓之间的层级关系,所以需要选择 cv2.RETR_TREE 的方式提取轮廓;第三个参数表示得到的轮廓存储方式,由于我们提取的轮廓均为四边形,因此采用 cv2.CHAIN_APPROX_SIMPLE 这个参数(只保留轮廓的四个角点)。findContours 第一个返回值并不重要,第二个和第三个分别是找到的轮廓集合和各轮廓之间的层级关系, 后面的判断与轮廓的层级密切相关。
_, contours, hierarchy = cv2.findContours(
edges.copy(), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
hierarchy = hierarchy[0]
接下来需要对所有提取得到的轮廓进行筛选,第一个筛选条件是轮廓的形状,选出所有接近正方形的轮廓,排除掉异形轮廓和长方形轮廓。之后对轮廓的层级进行筛选,筛选出具有四层及以上嵌套的轮廓,筛选层级时需要按照之前一步得到的轮廓层级 hierarchy 一层层向下递归直到其下一个子轮廓不存在为止。
found = []
for i in range(len(contours)):
k = i # 层次递归指针
c = 0 # 层级计数
# 获取轮廓包围框及其长宽
rect = cv2.minAreaRect(contours[i])
w = rect[1][0]
h = rect[1][1]
if w and h:
rate = min(w, h) / max(w, h)
# 选取方形轮廓
if (rate >= 0.75 and w < img.shape[1] / 4 and h < img.shape[0] / 4):
# 判断轮廓层级,筛选多层轮廓的外围轮廓
while hierarchy[k][2] != -1:
k = hierarchy[k][2]
c = c + 1
# 超过4层则判断为定位点
if c >= 4:
found.append(i)
continue
else:
continue
temp_contours = []
for i in found:
temp_contours.append(contours[i])
到这一步得到的 temp_contours 是所有筛选出来符合层级数量和形状要求的外围轮廓,但其中并不一定每个轮廓和每个定位点一一对应,可能存在多个轮廓与同一定位点对应的情况,因此还需要对其中轮廓进行二次筛选。
首先我们有理由相信,对于同一定位点的轮廓约外围的轮廓越大,因此可以对轮廓依面积进行一次排序,之后对 temp_contours 中所有轮廓两两判断是否存在轮廓重叠的情况,如果存在则判断为属于同一定位点的轮廓并将之去除。
candidate_contours = []
candidate_contours.append(contours[0])
for i in range(1, 12):
if is_duplicate(contours[i], candidate_contours):
continue
else:
candidate_contours.append(contours[i])
其中 is_duplicate 用于判断两个轮廓是否同一定位点的不同轮廓。
def is_duplicate(c, contours):
"""
判断当前轮廓是否与其他轮廓属于同一定位点,用于筛选同一定位点的唯一轮廓
:param c: 当前判断的 contour
:param contours: 当前所有非重叠 contours 集合
:return: contours 中是否存在与 c 相交的轮廓,bool,重复轮廓返回 True,不重复返回 False
"""
r = cv2.boundingRect(c)
for contour in contours:
rect = cv2.boundingRect(contour)
if _intersection(r, rect):
return True
else:
continue
return False
is_duplicate 方法中调用的 _intersection 用于计算两个 rect 是否相交。事实上两个方法应该可以合并,但能力有限加上懒惰就索性分开写了。
def _intersection(a, b):
"""
判断两个 rect 是否相交
:param a: rect1
:param b: rect2
:return: 是否相交,bool, 相交为 True,不相交为 False
"""
x = max(a[0], b[0])
y = max(a[1], b[1])
w = min(a[0] + a[2], b[0] + b[2]) - x
h = min(a[1] + a[3], b[1] + b[3]) - y
if w < 0 or h < 0:
return False # or (0, 0, 0, 0) ?
return True
经过上述步骤,得到的 candidate_contours 已经是符合:1. 形状接近正方形;2. 具有四层及以上子轮廓;3. 唯一对应定位点;4. 按面积从大到小排序,四个条件的定位点轮廓集合,基本可以确定是四个定位点的外围轮廓无疑,但为了防止还会出现背景中其他的干扰因素,因此按面积选取最大的四块作为最终选择的定位点轮廓。选择出的四个轮廓对其分别取得他们的中心点,作为色卡提取的四个角点。
if len(candidate_contours) >= 4:
candidate_contours = candidate_contours[0:4]
for i in range(4):
cv2.drawContours(img_dc, candidate_contours, i,
(0, 0, 255), 2, cv2.LINE_AA)
location_points = []
for i in range(0, 4):
pos_rect = cv2.minAreaRect(candidate_contours[i])
location_points.append(pos_rect[0])
# 对定位点排序,排序后顺序为:左上,右上,左下,右下
location_points = SortPoint(location_points)
return location_points
else:
print(len(contours))
for i in range(len(contours)):
cv2.drawContours(img_dc, contours, i,
(255, 255, 0), 2, cv2.LINE_AA)
print("未找到足够的定位点")
其中使用到 SortPoint 方法,用于将获取到的四个点按照我们需要的顺序排序,分别对应色卡的左上,右上,左下,右下四个角点。
def SortPoint(points):
"""
对四个定位点进行排序,排序后顺序分别为左上、右上、左下、右下
:param points: 待排序的点集
:return: 排序完成的点集
"""
sp = sorted(points, key=lambda x: (int(x[1]), int(x[0])))
if sp[0][0] > sp[1][0]:
sp[0], sp[1] = sp[1], sp[0]
if sp[2][0] > sp[3][0]:
sp[2], sp[3] = sp[3], sp[2]
return sp
获取到四个角点后,可以使用 cv2.warpPerspective 方法对色卡部分进行提取并透视变换为正视图。
# 透视变换,转换为正视图
pts1 = np.float32(sp)
pts2 = np.float32([[0, 0], [1000, 0], [0, 750], [1000, 750]])
transform = cv2.getPerspectiveTransform(pts1, pts2)
warpedimg = cv2.warpPerspective(img, transform, (1000, 750))
由于选取的角点位于定位点中央,因此此时获取得到的色卡四角仍有定位点的一部分存留,为了将其去除,可以使用 cv2.resize 对图像进行裁剪切边,只保留中央色卡部分。
# 设定裁剪边距,完全去除定位标志
padding = np.int0(warpedimg.shape[0] * 0.06)
img_cropped = warpedimg[padding:(
warpedimg.shape[0] - padding), padding:(warpedimg.shape[1] - padding)]
img_output = cv2.resize(img_cropped, (400, 300),
interpolation=cv2.INTER_CUBIC)
最终得到的 img_output 即为色卡本身的正视图,可以使用横纵向的各个比例或者再使用轮廓提取获得各个色块的颜色。这部分可能会在下文关于颜色校正中说明。
我相信本文的内容可能会有更简单的方式解决,但迫于实际水平有限,实在没法快速掌握 OpenCV 提供的各种方法,只能采用这种相对复杂的方式将其完成。撇开效率不谈,这样的方式起码有效性可以保证,对于我们的使用场景(以正面拍摄为主)比较适用,也在实现过程中对 OpenCV 有了一定的了解,尤其是 Contour 部分有了比较深刻的认识。