学习笔记:从零实现一个"扫描全能王"式文档扫描仪
一、前言
最近在学习OpenCV,发现一个非常有趣的应用------实时文档扫描。只要把摄像头对准一张纸、一本书或任何四边形物体,程序就能自动检测边缘、提取轮廓,并把倾斜的视角"拉正"成一张平整的俯视图。类似手机上的"扫描全能王"或Office Lens。
本文基于OpenCV和Python,详细拆解每一步的实现原理和代码细节,作为自己的学习记录,也希望能帮助到同样在入门CV的朋友。
二、整体流程概览
整个程序的核心逻辑如下:
-
打开摄像头,读取每一帧图像
-
预处理(灰度、高斯模糊、Canny边缘检测)
-
寻找轮廓,筛选出面积最大且近似为四边形的区域
-
对检测到的四个顶点进行排序(左上、右上、右下、左下)
-
执行透视变换,将倾斜的文档拉正
-
二值化增强对比度,获得类似扫描件的效果
-
循环以上步骤,按ESC键退出
三、代码逐模块解析
1. 摄像头读取与显示辅助
cap = cv2.VideoCapture(0)
while True:
ret, image = cap.read()
if not ret:
break
# 显示图像并检测ESC退出
if cv_show("image", image):
break
-
cv2.VideoCapture(0)打开默认摄像头,0通常代表内置摄像头。 -
cap.read()返回两个值:ret(布尔,是否成功读取)和image(当前帧的BGR图像数组)。 -
自定义的
cv_show()函数封装了imshow和按键检测,按下ESC返回True,便于退出循环。
2. 预处理:降噪 + 边缘检测
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5,5), 0)
edged = cv2.Canny(gray, 15, 45)
-
灰度化:减少计算量,边缘检测基于亮度变化。
-
高斯模糊:去除图像中的噪点,避免把细小的纹理误判为边缘。核大小为5x5,标准差为0(自动计算)。
-
Canny边缘检测:双阈值法(15和45),提取出明显的亮度梯度变化区域。低阈值与高阈值的比例一般设为1:2或1:3,这里高阈值是低阈值的3倍。
3. 轮廓检测与筛选
cnts = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:3]
-
findContours返回轮廓列表,使用RETR_EXTERNAL只检测最外层轮廓(忽略内部空洞)。 -
CHAIN_APPROX_SIMPLE压缩轮廓点,节省内存。 -
[-2]是因为不同OpenCV版本返回值数量不同(有的返回两个值,有的三个),取倒数第二个总能得到轮廓列表。 -
按轮廓面积降序排序,只保留面积最大的前3个轮廓------文档通常是画面中的主要物体,这样能过滤掉小噪声。
4. 四边形近似与判定
for cnt in cnts:
peri = cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, 0.05 * peri, True)
area = cv2.contourArea(approx)
if area > 30000 and len(approx) == 4:
screenCnt = approx
flag = 1
break
-
arcLength计算轮廓周长,True表示轮廓闭合。 -
approxPolyDP用Douglas-Peucker算法进行多边形近似,第二个参数0.05*peri表示近似精度(轮廓周长的5%)。值越小,近似越精确;值越大,顶点数越少。 -
筛选条件:近似多边形的面积大于30000像素(避免误识别小物体)且顶点数恰好为4。这样我们就找到了一个四边形,即文档的边界。
5. 顶点排序(关键难点)
透视变换要求四个点的顺序是固定的(左上、右上、右下、左下)。但approxPolyDP返回的点是杂乱无章的,直接变换会严重扭曲。
def order_points(pts):
rect = np.zeros((4, 2), dtype="float32")
s = pts.sum(axis=1) # x+y
rect[0] = pts[np.argmin(s)] # 左上(和最小)
rect[2] = pts[np.argmax(s)] # 右下(和最大)
diff = np.diff(pts, axis=1) # y-x
rect[1] = pts[np.argmin(diff)] # 右上(差最小)
rect[3] = pts[np.argmax(diff)] # 左下(差最大)
return rect
原理说明:
-
左上角的点:
x+y最小 -
右下角的点:
x+y最大 -
右上角的点:
y-x最小(因为x较大y较小) -
左下角的点:
y-x最大(因为x较小y较大)
这个几何关系在文档大致正向面对摄像头时成立。如果文档旋转角度过大,这种方法可能失效,但在大多数手持拍摄场景下足够鲁棒。
6. 透视变换
def four_point_transform(img, pts):
rect = order_points(pts)
(tl, tr, br, bl) = rect
# 计算变换后的宽度(取上下两边长度的最大值)
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(img, M, (maxWidth, maxHeight))
return warped
-
计算原始四边形上下边、左右边的欧氏距离,取较大值作为透视变换后的图像宽高,避免内容被裁剪。
-
getPerspectiveTransform根据源点和目标点计算3x3透视变换矩阵。 -
warpPerspective应用变换,输出拉正后的图像。
7. 二值化增强
warped = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
ref = cv2.threshold(warped, 20, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
-
将彩色矫正图转为灰度。
-
threshold结合OTSU算法自动计算最佳二值化阈值(忽略手动设定的20),输出黑白扫描件效果,文字更清晰。
四、完整代码
import numpy as np
import cv2
def order_points(pts):
rect = np.zeros((4, 2), dtype="float32")
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)]
rect[2] = pts[np.argmax(s)]
diff = np.diff(pts, axis=1)
rect[1] = pts[np.argmin(diff)]
rect[3] = pts[np.argmax(diff)]
return rect
def four_point_transform(img, pts):
rect = order_points(pts)
(tl, tr, br, bl) = rect
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(img, M, (maxWidth, maxHeight))
return warped
def cv_show(name, img):
cv2.imshow(name, img)
key = cv2.waitKey(1) & 0xFF
if key == 27: # ESC
return True
return False
# 主程序
cap = cv2.VideoCapture(0)
if not cap.isOpened():
print("摄像头打开失败")
exit()
while True:
flag = 0
ret, image = cap.read()
if not ret:
print("无法读取帧")
break
orig = image.copy()
cv_show("original", image)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5,5), 0)
edged = cv2.Canny(gray, 15, 45)
cv_show("edged", edged)
cnts = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:3]
for cnt in cnts:
peri = cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, 0.05*peri, True)
area = cv2.contourArea(approx)
if area > 30000 and len(approx) == 4:
screenCnt = approx
flag = 1
print("检测到文档,周长={:.1f} 面积={:.1f}".format(peri, area))
break
if flag == 1:
cv2.drawContours(image, [screenCnt], -1, (0,0,255), 3)
cv_show("detected", image)
warped = four_point_transform(orig, screenCnt.reshape(4,2))
cv_show("warped", warped)
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
ref = cv2.threshold(warped_gray, 20, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
cv_show("scanned", ref)
key = cv2.waitKey(1) & 0xFF
if key == 27:
break
cap.release()
cv2.destroyAllWindows()
五、运行效果与注意事项
效果演示

-
将一张A4纸或卡片放在摄像头前,程序会用红色边框标出文档轮廓。
-
自动拉正并生成黑白扫描图,效果堪比简易扫描仪。
踩坑提醒
-
光线条件:光线太暗或背景纹理复杂时,Canny边缘可能不完整,导致找不到四边形。可以尝试调整Canny的阈值(如改为50,100)或增强对比度。
-
面积阈值30000 :这个值需要根据摄像头分辨率调整。如果相机是640x480,该值可能偏大;如果是1920x1080,可以适当增大。建议改成
area > (image.shape[0]*image.shape[1])*0.2(占画面的20%)。 -
顶点排序的局限性:当文档旋转超过±45°时,"左上角x+y最小"的规则会失效。更鲁棒的方法是用凸包和角度排序,但本代码应付日常正面拍摄已足够。
-
性能:实时处理每一帧,对CPU有一定要求。可以降低每帧分辨率或跳帧处理。
六、改进方向
-
动态阈值:根据图像亮度自适应调整Canny参数。
-
增加透视校正后的锐化:使文字更清晰。
-
保存扫描图片:按空格键保存当前扫描结果。
-
增加鼠标选点:如果自动检测失败,允许用户手动点选四个角点。
七、总结
通过这个小项目,我深入理解了:
-
图像预处理流程(灰度→高斯→Canny)
-
轮廓的查找、筛选与多边形近似
-
透视变换的数学原理及顶点排序技巧
-
OpenCV的实时视频流处理框架
代码虽然不长,但涵盖了计算机视觉中非常经典的问题------文档图像矫正。希望这篇笔记能对你有所启发,也欢迎交流讨论!
📌 本文仅为个人学习记录,代码参考了OpenCV官方教程及《Python计算机视觉编程》相关内容。
💬 如有错误或疑问,欢迎在评论区指出。