环境
- Python 3.8
- OpenCV 4.4
最小外接矩形
矩形操作是我们在OpenCV
里最常用的操作,其中最为常见的就是包围框(Bounding Box
)和旋转矩形(Rotated Box
)。 其中包围框是最为常见的,对应OpenCV
中的boundingRect()
,使用正矩形框处物体,一般多用在目标检测中。使用包围框框柱目标物体,这种操作比较简单,但是通常框中也会有一些其他的区域。其次就是使用旋转矩形,也叫最小外接矩形,对应OpenCV
中的minAreaRect()
,用来使用旋转矩形最大限度的框出目标物体,减小背景干扰,在OCR
任务中较为常用。
def drow_box(img, cnt):
rect_box = cv2.boundingRect(cnt)
rotated_box = cv2.minAreaRect(cnt)
cv2.rectangle(img, (rect_box[0], rect_box[1]), (rect_box[0] + rect_box[2], rect_box[1] + rect_box[3]), (0, 255, 0), 2)
box = cv2.boxPoints(rotated_box)
box = np.int0(box)
cv2.drawContours(img, [box], 0, (0, 0, 255), 2)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
# plt.imshow(img)
# plt.show()
return img, rotated_box, box
minAreaRect()
返回了所需区域的最小斜矩形的参数,与包围框直接返回四个顶点的坐标不同,最小外接矩形返回的是矩形的((x, y), (w, h), angle)
,对应了矩形的中心,宽度,高度和旋转角度。
旋转角度angle
是水平轴(x
轴)逆时针旋转,与碰到的矩形的第一条边的夹角。并且这个边的边长是width
,另一条边边长是height
。也就是说,在这里width
与height
不是按照长短来定义的。
在OpenCV
中,坐标系原点在左上角,相对于x
轴,逆时针旋转角度为负,顺时针旋转角度为正,所以函数minAreaRect()
返回的角度范围时[-90~0)
。想象一个平放的长矩形,调用minAreaRect()
返回的角度为-90
度。如果我们旋转图像,直到矩形树立起来,这是调用minAreaRect()
得到的角度依然是-90
度。
图像抠取
仿射变换
第一种裁剪旋转矩形的方法是通过仿射变换旋转图像的方式。
仿射变换(Affine Transformation
) 是一种二维坐标到二维坐标之间的线性变换,保持二维图形的“平直性”(straightness
,即变换后直线还是直线不会打弯,圆弧还是圆弧)和“平行性”(parallelness
,其实是指保二维图形间的相对位置关系不变,平行线还是平行线,相交直线的交角不变。)。
计算过程:
- 计算旋转矩形。
- 基于旋转矩形的中心和角度计算得到一个变换矩阵。这里我想要得到的车牌是一个横着的正矩形,因此需要判断一下
angle
,width
和height
,保证旋转后的矩形是横着的矩形。 - 以旋转矩形的中心为基准点,对整张图进行旋转,这里旋转的实现是基于仿射变换实现的。
- 由于旋转之后矩形的中点坐标是不变的,以中心为基础,通过
width
和height
抠出正矩形。
def crop1(img, cnt):
horizon = True
img, rotated_box, _ = drow_box(img, cnt)
center, size, angle = rotated_box[0], rotated_box[1], rotated_box[2]
center, size = tuple(map(int, center)), tuple(map(int, size))
print(angle)
if horizon:
if size[0] < size[1]:
angle -= 270
w = size[1]
h = size[0]
else:
w = size[0]
h = size[1]
size = (w, h)
height, width = img.shape[0], img.shape[1]
M = cv2.getRotationMatrix2D(center, angle, 1)
img_rot = cv2.warpAffine(img, M, (width, height))
img_crop = cv2.getRectSubPix(img_rot, size, center)
show([img, img_rot, img_crop])
如果不做边长和角度的判断,则只会沿着x
轴的顺时针方向做相同大小角度的旋转,不能保证旋转后的视角是正确的视角:
根据任务目标的类型,做边长和角度的判断并进行相应的调整,可以保证旋转后的视角是正确的视角:
透视变换
第二种裁剪旋转矩形的方法是通过透视变换直接将旋转矩形的四个顶点映射到正矩形的四个顶点。
透视变换(Perspective Transformation
)是将图片投影到一个新的视平面(Viewing Plane
),也称作投影映射(Projective Mapping
)。
计算过程:
- 计算旋转矩形。
- 基于矩形的四个顶点和想要抠出的正矩形的四个顶点得到一个变换矩阵。这里我想要得到的车牌是一个横着的正矩形,因此需要判断一下
width
和height
,将映射的平面定义为一个横着的正矩形。 - 通过透视变换,将四个点组成的平面转换成另四个点组成的一个平面,以此抠出正矩形。
def crop2(img, cnt):
img, rotated_box, box = drow_box(img, cnt)
width = int(rotated_box[1][0])
height = int(rotated_box[1][1])
print(width, height)
if width > height:
w = width
h = height
else:
w = height
h = width
src_pts = box.astype("float32")
dst_pts = np.array([[w - 1, h - 1],
[0, h - 1],
[0, 0],
[w - 1, 0]], dtype="float32")
M = cv2.getPerspectiveTransform(src_pts, dst_pts)
warped = cv2.warpPerspective(img, M, (w, h))
show([img, warped])
结论
以上两种方法都可以用来抠取旋转矩形的内容。仿射变换方法需要预先对整张图进行旋转,通过观察旋转后的图像可以发现,有一部分图像被旋转出了图像边界,如果你要抠取的目标正好在图像边缘附近,那么很容易出界导致图像抠取的缺失。同时我们需要对宽、高和角度做出动态的调整;透视变换的方法直接对抠取区域进行了映射,这种方法可以省略旋转的步骤,并且不会出现抠取内容的缺失。同时我们只需要对4个顶点之间的映射关系做好定义即可,不需要考虑角度的问题。相对的,透视变换相对于仿射变换计算量更大一些,不过这在c++
的底层实现上带来的时延差距小于ms
。