一、摄像头读取与图像显示
文档扫描程序首先使用摄像头获取实时画面:
cap = cv2.VideoCapture(0)
这里的0表示使用默认摄像头。
为了避免摄像头无法正常打开,需要进行判断:
if not cap.isOpened():
print("Cannot open camera")
exit()
打开摄像头之后,通过循环不断读取画面:
ret, image = cap.read()
其中ret用于判断当前帧是否读取成功,image就是当前获取到的图像。
程序还保存了一份原始图像:
orig = image.copy()
这是因为后面会对图像进行各种处理,而透视变换时仍然需要使用最初读取到的原始图像。
在循环中可以通过:
cv_show('image', image)
显示摄像头画面。
这里自己定义了一个显示函数:
def cv_show(name, img):
cv2.imshow(name, img)
cv2.waitKey(1)
这样在后面的代码中直接调用cv_show()即可,不需要重复编写imshow()和waitKey()。
二、文档检测的图像预处理
从摄像头获得彩色图像之后,首先需要将它转换为灰度图:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
文档检测主要关注的是文档的边缘,而不是具体的颜色,所以灰度图已经可以满足后续处理的需要。
接下来使用高斯模糊:
gray = cv2.GaussianBlur(gray, (5, 5), 0)
高斯模糊可以降低图像中的一些噪声,使后面的边缘检测更加稳定。
然后使用Canny算法检测边缘:
edged = cv2.Canny(gray, 30, 150)
经过Canny处理以后,图像中的物体边缘会被突出显示。
因此这一部分的处理流程可以简单表示为:
摄像头原始图像
↓
灰度化
↓
高斯模糊
↓
Canny边缘检测
↓
边缘图
在文档扫描中,这一步非常重要,因为后面的轮廓检测就是建立在边缘图的基础上。
三、轮廓检测与文档筛选
得到边缘图之后,就可以进行轮廓检测:
cnts = cv2.findContours(
edged,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)[-2]
这里使用RETR_EXTERNAL主要寻找最外层轮廓。
找到轮廓以后,再按照轮廓面积从大到小进行排序:
cnts = sorted(
cnts,
key=cv2.contourArea,
reverse=True
)
为什么要排序?
因为拍摄的画面中可能存在很多轮廓,而文档通常是画面中比较大的目标。因此优先检查面积较大的轮廓,可以更容易找到文档。
接下来遍历这些轮廓:
for c in cnts:
首先计算轮廓周长:
peri = cv2.arcLength(c, True)
然后使用approxPolyDP()进行轮廓近似:
approx = cv2.approxPolyDP(
c,
0.02 * peri,
True
)
轮廓近似的作用是将原本比较复杂的轮廓,用更少的点表示。
对于一张比较规则的纸张来说,它通常可以近似成一个四边形,因此程序判断:
area = cv2.contourArea(approx)
if area > 5000 and len(approx) == 4:
screenCnt = approx
这里设置了两个条件:
面积大于5000,并且轮廓近似后正好有4个点。
满足这两个条件后,就认为当前轮廓很可能是要检测的文档。
因此,文档检测的核心实际上可以概括为:
寻找轮廓
↓
按照面积排序
↓
轮廓近似
↓
判断面积
↓
判断是否为4个点
↓
确定文档四个角点
这部分是整个文档扫描程序中非常关键的一步。
四、四个角点的排序
检测到文档以后,并不能直接进行透视变换。
原因是检测出来的4个点并没有固定的顺序,因此需要先将它们按照:
左上、右上、右下、左下
进行排序。
代码中定义了order_points()函数:
def order_points(pts):
rect = np.zeros((4, 2), dtype="float32")
首先创建一个4×2的数组,用于保存排序后的四个坐标。
通过:
s = pts.sum(axis=1)
计算每个点的x+y。
一般来说:
-
x+y最小 → 左上角; -
x+y最大 → 右下角。
因此:
rect[0] = pts[np.argmin(s)]
rect[2] = pts[np.argmax(s)]
再计算:
diff = np.diff(pts, axis=1)
利用x-y的关系确定另外两个点:
-
x-y最小 → 左下角; -
x-y最大 → 右上角。
最后得到固定顺序:
rect[0] → 左上
rect[1] → 右上
rect[2] → 右下
rect[3] → 左下
这种点排序方法是透视变换中比较常见的处理方式。
五、透视变换
获得正确顺序的四个角点以后,就可以进行透视变换。
首先计算文档的宽度。
代码分别计算上下两条边的长度:
widthA = np.sqrt(
((br[0] - bl[0]) ** 2) +
((br[1] - bl[1]) ** 2)
)
widthB = np.sqrt(
((tr[0] - tl[0]) ** 2) +
((tr[1] - tl[1]) ** 2)
)
然后取两者较大的值作为新的图像宽度:
maxWidth = max(
int(widthA),
int(widthB)
)
高度的计算也是类似的:
heightA = np.sqrt(
((tr[0] - br[0]) ** 2) +
((tr[1] - br[1]) ** 2)
)
heightB = np.sqrt(
((tl[0] - bl[0]) ** 2) +
((tl[1] - bl[1]) ** 2)
)
最终得到:
maxHeight = max(
int(heightA),
int(heightB)
)
接下来定义目标图像的四个角点:
dst = np.array([
[0, 0],
[maxWidth - 1, 0],
[maxWidth - 1, maxHeight - 1],
[0, maxHeight - 1]
], dtype="float32")
原始图像中的四个角点和目标图像中的四个角点一一对应。
然后计算透视变换矩阵:
M = cv2.getPerspectiveTransform(
rect,
dst
)
最后使用:
warped = cv2.warpPerspective(
image,
M,
(maxWidth, maxHeight)
)
完成透视变换。
这样原本可能存在倾斜、变形的文档,就可以被转换成比较规整的矩形图像。
可以把这个过程理解成:
摄像头拍摄的倾斜文档
↓
找到四角
↓
排序四个角点
↓
计算透视变换矩阵
↓
重新映射像素
↓
得到规整文档
代码中的four_point_transform()函数就是将这些步骤组合起来完成透视变换。
六、透视后的图像二值化
完成透视变换之后,还可以进一步对文档进行二值化。
首先转换成灰度图:
warped = cv2.cvtColor(
warper,
cv2.COLOR_BGR2GRAY
)
然后使用阈值处理:
ref = cv2.threshold(
warped,
200,
255,
cv2.THRESH_BINARY
)[1]
这里设置阈值为200。
像素值高于阈值的区域会被处理成白色,否则会被处理成黑色。
经过二值化之后,可以让文档中的文字和背景之间产生更加明显的区别。
所以这个文档扫描程序最终实际上完成了:
原始图像
↓
灰度化
↓
高斯模糊
↓
Canny边缘检测
↓
轮廓检测
↓
轮廓近似
↓
寻找四个角点
↓
透视变换
↓
灰度化
↓
二值化
↓
扫描后的文档
七、使用DNN进行图像预处理
第二个程序开始接触OpenCV中的DNN模块。
首先读取一张图像:
image = cv2.imread('pinjie.jpg')
然后获取图像尺寸:
(h, w) = image.shape[:2]
在普通OpenCV处理中,图像通常以H×W×C的形式存在,但是神经网络模型通常有自己的输入数据格式,因此需要进行预处理。
这里使用:
blob = cv2.dnn.blobFromImage(
image,
1,
(w, h),
(0, 0, 0),
swapRB=False,
crop=False
)
blobFromImage()可以将普通图像转换成适合神经网络输入的Blob数据。
其中几个参数比较重要:
scalefactor表示像素值缩放比例。
size表示输入图像的尺寸。
mean表示均值减法。
swapRB用于交换R、B通道。
crop用于控制调整大小之后是否进行中心裁剪。
OpenCV默认使用BGR通道顺序,而很多深度学习模型使用RGB,因此在使用模型时,需要特别注意swapRB参数。
八、加载神经网络模型并进行推理
完成图像预处理以后,需要加载训练好的模型。
代码中使用:
net = cv2.dnn.readNetFromTorch(
r'model\starry_night.t7'
)
这里使用的是readNetFromTorch(),说明加载的是PyTorch模型。
OpenCV的DNN模块可以支持多种深度学习模型格式,例如代码注释中提到的:
-
Caffe
-
TensorFlow
-
Torch
-
Darknet
-
ONNX
-
OpenVINO
加载模型之后,需要将前面得到的Blob作为模型输入:
net.setInput(blob)
然后执行前向传播:
out = net.forward()
forward()就是让输入数据经过神经网络计算,最终得到模型输出。
整个过程可以理解为:
原始图像
↓
blobFromImage()
↓
Blob数据
↓
setInput()
↓
输入神经网络
↓
forward()
↓
模型输出
这里实际上已经开始接触深度学习模型推理的基本流程了。
九、理解神经网络输出的BCHW格式
模型输出out并不是普通的图片格式,而是一个四维数组。
代码中将它理解为:
B × C × H × W
其中:
-
B:Batch,一次处理的图像数量; -
C:Channel,通道数; -
H:Height,高度; -
W:Width,宽度。
代码通过:
out_new = out.reshape(
out.shape[1],
out.shape[2],
out.shape[3]
)
去掉第一维Batch,将四维数据转换成三维数据。
也就是说:
B × C × H × W
↓
C × H × W
但是OpenCV显示图像时通常需要:
H × W × C
因此还需要进行维度转置:
result = out_new.transpose(1, 2, 0)
也就是:
C × H × W
↓
H × W × C
这个过程对于理解深度学习模型的输入输出格式非常重要。
十、输出归一化与结果显示
模型输出之后,还需要进行归一化:
cv2.normalize(
out_new,
out_new,
norm_type=cv2.NORM_MINMAX
)
归一化可以将数据调整到指定范围,使输出结果更加适合后续处理和显示。
完成归一化和维度转换后:
result = out_new.transpose(1, 2, 0)
最后就可以使用OpenCV显示:
cv2.imshow('result', result)
cv2.waitKey(0)
cv2.destroyAllWindows()
因此,DNN部分的完整流程可以总结为:
读取图像
↓
获取图像尺寸
↓
blobFromImage预处理
↓
加载PyTorch模型
↓
setInput设置输入
↓
forward前向传播
↓
得到四维输出
↓
reshape调整维度
↓
normalize归一化
↓
transpose调整通道顺序
↓
显示结果