Python+OpenCV实现文档扫描与旋转校正

你是不是也遇到过这样的烦恼?用手机拍了一张文档照片,结果拍歪了,或者因为角度问题,文字看起来是倾斜的。这时候如果直接打印或者上传,效果肯定不好。手动调整吧,又麻烦又费时间。

其实,这种问题完全可以用代码来解决。今天我就来分享一个用Python和OpenCV实现的文档扫描与旋转校正方案。这个方案能自动检测文档边缘,进行透视变换,还能校正旋转角度,最后还能增强图像质量。整个过程就像给文档拍了个“证件照”,让它变得规规矩矩。

1. 环境准备与快速部署

1.1 安装必要的库

首先,确保你的Python环境已经安装好。我建议使用Python 3.7或更高版本。然后,通过pip安装我们需要的库:

pip install opencv-python
pip install numpy
pip install scikit-image

如果你用的是Anaconda,也可以用conda安装:

conda install -c conda-forge opencv
conda install numpy scikit-image

1.2 验证安装

安装完成后,可以写个简单的脚本来验证一下:

import cv2
import numpy as np
from skimage import io

print(f"OpenCV版本: {cv2.__version__}")
print(f"NumPy版本: {np.__version__}")

如果运行没有报错,说明环境已经准备好了。

2. 核心功能实现

2.1 边缘检测与轮廓查找

文档扫描的第一步是找到文档的边界。我们通常用边缘检测算法来找出文档的轮廓。

def find_document_contour(image_path):
    # 读取图像
    image = cv2.imread(image_path)
    if image is None:
        print(f"无法读取图像: {image_path}")
        return None
    
    # 保存原始图像副本
    original = image.copy()
    
    # 转换为灰度图
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # 高斯模糊,减少噪声
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    
    # Canny边缘检测
    edged = cv2.Canny(blurred, 50, 150)
    
    # 查找轮廓
    contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    # 按面积排序,取最大的几个轮廓
    contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
    
    # 寻找近似矩形的轮廓
    document_contour = None
    for contour in contours:
        # 计算轮廓周长
        perimeter = cv2.arcLength(contour, True)
        # 多边形近似
        approx = cv2.approxPolyDP(contour, 0.02 * perimeter, True)
        
        # 如果是四边形(文档通常是矩形)
        if len(approx) == 4:
            document_contour = approx
            break
    
    return original, document_contour

2.2 透视变换

找到文档轮廓后,我们需要进行透视变换,把倾斜的文档"拉正"。

def perspective_transform(image, contour):
    # 获取轮廓的四个顶点
    points = contour.reshape(4, 2)
    
    # 计算四个顶点的中心点
    center = np.mean(points, axis=0)
    
    # 根据中心点将顶点分为左上、右上、右下、左下
    diff = points - center
    angles = np.arctan2(diff[:, 1], diff[:, 0])
    
    # 按角度排序
    sorted_indices = np.argsort(angles)
    points = points[sorted_indices]
    
    # 确保顺序是:左上、右上、右下、左下
    # 调整顺序,使第一个点是左上角
    if points[0][0] > points[1][0]:
        points = np.roll(points, -1, axis=0)
    
    # 定义目标矩形的尺寸
    # 计算原始文档的宽度和高度
    width_top = np.linalg.norm(points[1] - points[0])
    width_bottom = np.linalg.norm(points[2] - points[3])
    max_width = max(int(width_top), int(width_bottom))
    
    height_left = np.linalg.norm(points[3] - points[0])
    height_right = np.linalg.norm(points[2] - points[1])
    max_height = max(int(height_left), int(height_right))
    
    # 目标矩形的四个顶点
    dst_points = np.array([
        [0, 0],
        [max_width - 1, 0],
        [max_width - 1, max_height - 1],
        [0, max_height - 1]
    ], dtype="float32")
    
    # 计算透视变换矩阵
    matrix = cv2.getPerspectiveTransform(points.astype("float32"), dst_points)
    
    # 应用透视变换
    warped = cv2.warpPerspective(image, matrix, (max_width, max_height))
    
    return warped

2.3 旋转角度检测与校正

有时候文档本身是正的,但是拍照时旋转了。这时候我们需要检测旋转角度并进行校正。

def detect_rotation_angle(image):
    """
    检测图像的旋转角度
    返回:旋转角度(度)
    """
    # 转换为灰度图
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # 二值化
    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    
    # 查找轮廓
    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    if not contours:
        return 0
    
    # 找到最大的轮廓(假设是文档主体)
    largest_contour = max(contours, key=cv2.contourArea)
    
    # 计算最小外接矩形
    rect = cv2.minAreaRect(largest_contour)
    angle = rect[2]
    
    # 调整角度范围到[-45, 45]
    if angle < -45:
        angle = 90 + angle
    elif angle > 45:
        angle = angle - 90
    
    return angle

def rotate_image(image, angle):
    """
    旋转图像
    """
    if abs(angle) < 1:  # 角度太小,不旋转
        return image
    
    # 获取图像尺寸
    (h, w) = image.shape[:2]
    center = (w // 2, h // 2)
    
    # 计算旋转矩阵
    M = cv2.getRotationMatrix2D(center, angle, 1.0)
    
    # 计算旋转后的图像尺寸
    cos = np.abs(M[0, 0])
    sin = np.abs(M[0, 1])
    
    new_w = int((h * sin) + (w * cos))
    new_h = int((h * cos) + (w * sin))
    
    # 调整旋转矩阵的平移分量
    M[0, 2] += (new_w / 2) - center[0]
    M[1, 2] += (new_h / 2) - center[1]
    
    # 执行旋转
    rotated = cv2.warpAffine(image, M, (new_w, new_h), 
                            flags=cv2.INTER_CUBIC, 
                            borderMode=cv2.BORDER_REPLICATE)
    
    return rotated

2.4 图像增强

校正后的文档可能对比度不够或者有噪点,我们可以进行一些增强处理。

def enhance_document(image):
    """
    增强文档图像质量
    """
    # 转换为灰度图
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # 自适应阈值二值化
    binary = cv2.adaptiveThreshold(gray, 255, 
                                   cv2.ADAPTIVE_THRESH_GAUSSIAN_C, 
                                   cv2.THRESH_BINARY, 11, 2)
    
    # 去噪
    denoised = cv2.medianBlur(binary, 3)
    
    # 形态学操作,去除小噪点
    kernel = np.ones((2, 2), np.uint8)
    cleaned = cv2.morphologyEx(denoised, cv2.MORPH_CLOSE, kernel)
    
    # 转换为BGR格式返回
    enhanced = cv2.cvtColor(cleaned, cv2.COLOR_GRAY2BGR)
    
    return enhanced

3. 完整流程示例

现在我们把所有功能组合起来,实现一个完整的文档扫描流程。

def scan_document(image_path, output_path=None):
    """
    完整的文档扫描流程
    """
    print(f"处理图像: {image_path}")
    
    # 1. 查找文档轮廓
    result = find_document_contour(image_path)
    if result is None:
        print("未找到文档轮廓")
        return None
    
    original, contour = result
    
    if contour is None:
        print("未找到合适的文档轮廓,尝试直接旋转校正...")
        # 如果没有找到轮廓,直接处理原图
        processed = original.copy()
    else:
        # 2. 透视变换
        print("进行透视变换...")
        warped = perspective_transform(original, contour)
        processed = warped
    
    # 3. 检测旋转角度
    print("检测旋转角度...")
    angle = detect_rotation_angle(processed)
    print(f"检测到旋转角度: {angle:.2f}度")
    
    # 4. 旋转校正
    if abs(angle) > 1:
        print("进行旋转校正...")
        processed = rotate_image(processed, angle)
    
    # 5. 图像增强
    print("进行图像增强...")
    enhanced = enhance_document(processed)
    
    # 6. 保存结果
    if output_path:
        cv2.imwrite(output_path, enhanced)
        print(f"结果已保存到: {output_path}")
    
    return enhanced

# 使用示例
if __name__ == "__main__":
    # 输入图像路径
    input_image = "document_photo.jpg"
    
    # 输出图像路径
    output_image = "scanned_document.jpg"
    
    # 执行文档扫描
    result = scan_document(input_image, output_image)
    
    if result is not None:
        # 显示结果
        cv2.imshow("原始图像", cv2.imread(input_image))
        cv2.imshow("扫描结果", result)
        cv2.waitKey(0)
        cv2.destroyAllWindows()

4. 实用技巧与进阶功能

4.1 批量处理多个文档

如果你有很多文档需要处理,可以写一个批量处理的函数:

import os
from glob import glob

def batch_scan_documents(input_folder, output_folder):
    """
    批量处理文件夹中的所有文档图像
    """
    # 创建输出文件夹
    os.makedirs(output_folder, exist_ok=True)
    
    # 查找所有图像文件
    image_extensions = ['*.jpg', '*.jpeg', '*.png', '*.bmp']
    image_files = []
    
    for ext in image_extensions:
        image_files.extend(glob(os.path.join(input_folder, ext)))
    
    print(f"找到 {len(image_files)} 个图像文件")
    
    # 批量处理
    for i, image_path in enumerate(image_files, 1):
        print(f"\n处理第 {i}/{len(image_files)} 个文件: {os.path.basename(image_path)}")
        
        # 生成输出路径
        filename = os.path.basename(image_path)
        name, ext = os.path.splitext(filename)
        output_path = os.path.join(output_folder, f"{name}_scanned{ext}")
        
        try:
            # 执行文档扫描
            result = scan_document(image_path, output_path)
            if result is not None:
                print(f"✓ 处理成功")
            else:
                print(f"✗ 处理失败")
        except Exception as e:
            print(f"✗ 处理出错: {str(e)}")
    
    print(f"\n批量处理完成!结果保存在: {output_folder}")

# 使用示例
batch_scan_documents("input_documents", "output_documents")

4.2 调整参数优化效果

不同的文档可能需要不同的参数。你可以根据实际情况调整这些参数:

def scan_document_with_params(image_path, params=None):
    """
    带参数调整的文档扫描
    """
    # 默认参数
    default_params = {
        'canny_low': 50,      # Canny边缘检测低阈值
        'canny_high': 150,    # Canny边缘检测高阈值
        'blur_size': 5,       # 高斯模糊核大小
        'approx_epsilon': 0.02,  # 多边形近似精度
        'min_angle': 1,       # 最小旋转角度(小于此值不旋转)
        'adaptive_block': 11,  # 自适应阈值块大小
        'adaptive_c': 2       # 自适应阈值常数
    }
    
    # 合并用户参数
    if params:
        default_params.update(params)
    
    params = default_params
    
    # 这里可以修改之前的函数,使用这些参数
    # 例如修改find_document_contour函数:
    # edged = cv2.Canny(blurred, params['canny_low'], params['canny_high'])
    # ...
    
    # 具体实现略,原理相同

4.3 添加GUI界面

如果你想让非技术人员也能使用这个工具,可以添加一个简单的GUI界面:

import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk
import cv2

class DocumentScannerApp:
    def __init__(self, root):
        self.root = root
        self.root.title("文档扫描工具")
        self.root.geometry("800x600")
        
        # 创建界面元素
        self.create_widgets()
        
        # 当前图像
        self.current_image = None
        self.processed_image = None
    
    def create_widgets(self):
        # 菜单栏
        menubar = tk.Menu(self.root)
        self.root.config(menu=menubar)
        
        file_menu = tk.Menu(menubar, tearoff=0)
        menubar.add_cascade(label="文件", menu=file_menu)
        file_menu.add_command(label="打开图像", command=self.open_image)
        file_menu.add_command(label="保存结果", command=self.save_image)
        file_menu.add_separator()
        file_menu.add_command(label="退出", command=self.root.quit)
        
        # 处理菜单
        process_menu = tk.Menu(menubar, tearoff=0)
        menubar.add_cascade(label="处理", menu=process_menu)
        process_menu.add_command(label="扫描文档", command=self.scan_document)
        process_menu.add_command(label="旋转校正", command=self.rotate_document)
        
        # 图像显示区域
        self.image_label = tk.Label(self.root, text="请打开一个图像文件", bg="lightgray")
        self.image_label.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
        
        # 状态栏
        self.status_var = tk.StringVar()
        self.status_var.set("就绪")
        status_bar = tk.Label(self.root, textvariable=self.status_var, 
                             bd=1, relief=tk.SUNKEN, anchor=tk.W)
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)
    
    def open_image(self):
        file_path = filedialog.askopenfilename(
            title="选择图像文件",
            filetypes=[("图像文件", "*.jpg *.jpeg *.png *.bmp")]
        )
        
        if file_path:
            try:
                # 读取图像
                self.current_image = cv2.imread(file_path)
                if self.current_image is not None:
                    self.display_image(self.current_image)
                    self.status_var.set(f"已打开: {file_path}")
                else:
                    messagebox.showerror("错误", "无法读取图像文件")
            except Exception as e:
                messagebox.showerror("错误", f"打开图像失败: {str(e)}")
    
    def scan_document(self):
        if self.current_image is None:
            messagebox.showwarning("警告", "请先打开一个图像文件")
            return
        
        self.status_var.set("正在处理...")
        self.root.update()
        
        try:
            # 这里调用之前实现的文档扫描函数
            # 为了简化示例,这里只是演示流程
            # 实际使用时应该调用完整的scan_document函数
            
            # 模拟处理过程
            import time
            time.sleep(1)  # 模拟处理时间
            
            # 显示处理结果
            self.display_image(self.current_image)  # 这里应该显示处理后的图像
            self.status_var.set("处理完成")
            
        except Exception as e:
            messagebox.showerror("错误", f"处理失败: {str(e)}")
            self.status_var.set("处理失败")
    
    def display_image(self, image):
        # 调整图像大小以适应窗口
        h, w = image.shape[:2]
        max_size = 600
        
        if h > max_size or w > max_size:
            scale = max_size / max(h, w)
            new_h, new_w = int(h * scale), int(w * scale)
            image = cv2.resize(image, (new_w, new_h))
        
        # 转换颜色空间
        image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        
        # 转换为PIL图像
        pil_image = Image.fromarray(image_rgb)
        
        # 转换为Tkinter图像
        tk_image = ImageTk.PhotoImage(pil_image)
        
        # 更新显示
        self.image_label.config(image=tk_image, text="")
        self.image_label.image = tk_image  # 保持引用
    
    def save_image(self):
        if self.processed_image is None:
            messagebox.showwarning("警告", "没有可保存的图像")
            return
        
        file_path = filedialog.asksaveasfilename(
            title="保存图像",
            defaultextension=".jpg",
            filetypes=[("JPEG图像", "*.jpg"), ("PNG图像", "*.png")]
        )
        
        if file_path:
            try:
                cv2.imwrite(file_path, self.processed_image)
                messagebox.showinfo("成功", f"图像已保存到: {file_path}")
                self.status_var.set(f"已保存: {file_path}")
            except Exception as e:
                messagebox.showerror("错误", f"保存失败: {str(e)}")

# 启动应用
if __name__ == "__main__":
    root = tk.Tk()
    app = DocumentScannerApp(root)
    root.mainloop()

5. 常见问题与解决方案

5.1 找不到文档轮廓怎么办?

如果文档背景复杂或者光照不均匀,可能找不到正确的轮廓。可以尝试:

  1. 调整Canny边缘检测参数:降低阈值可以检测到更多边缘,但可能会有更多噪声。
  2. 增加高斯模糊:使用更大的模糊核可以减少噪声。
  3. 预处理图像:先进行直方图均衡化或对比度拉伸。
def preprocess_image(image):
    """图像预处理"""
    # 转换为灰度图
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # 直方图均衡化
    equalized = cv2.equalizeHist(gray)
    
    # 对比度拉伸
    min_val = np.min(equalized)
    max_val = np.max(equalized)
    stretched = ((equalized - min_val) / (max_val - min_val) * 255).astype(np.uint8)
    
    return stretched

5.2 透视变换后图像变形怎么办?

如果透视变换后的图像看起来变形,可能是顶点顺序错了。可以添加额外的验证:

def validate_perspective_points(points):
    """
    验证透视变换的四个点是否合理
    """
    # 计算四边形的面积
    area = cv2.contourArea(points)
    
    # 计算长宽比
    width_top = np.linalg.norm(points[1] - points[0])
    width_bottom = np.linalg.norm(points[2] - points[3])
    height_left = np.linalg.norm(points[3] - points[0])
    height_right = np.linalg.norm(points[2] - points[1])
    
    # 检查长宽比是否合理(文档通常不是特别长或特别宽)
    aspect_ratio = max(width_top, width_bottom) / max(height_left, height_right)
    
    # 合理的文档长宽比通常在0.5到2之间
    if aspect_ratio < 0.5 or aspect_ratio > 2:
        return False
    
    # 检查面积是否足够大
    if area < 10000:  # 假设最小面积
        return False
    
    return True

5.3 旋转角度检测不准确怎么办?

对于某些特殊文档,旋转角度检测可能不准确。可以尝试:

  1. 使用霍夫变换检测直线:通过检测文档中的文本行方向来确定旋转角度。
  2. 结合多种方法:使用最小外接矩形和霍夫变换的结果进行加权平均。
  3. 人工校正:在GUI中添加手动旋转功能。
def detect_angle_with_hough(image):
    """
    使用霍夫变换检测旋转角度
    """
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # 边缘检测
    edges = cv2.Canny(gray, 50, 150, apertureSize=3)
    
    # 霍夫变换检测直线
    lines = cv2.HoughLines(edges, 1, np.pi/180, 100)
    
    if lines is None:
        return 0
    
    angles = []
    for line in lines[:20]:  # 取前20条直线
        rho, theta = line[0]
        
        # 只考虑接近水平或垂直的直线
        if theta < np.pi/4 or theta > 3*np.pi/4:
            angle = np.degrees(theta - np.pi/2)
        else:
            angle = np.degrees(theta)
        
        # 调整角度到[-45, 45]
        if angle > 45:
            angle = angle - 90
        elif angle < -45:
            angle = 90 + angle
        
        angles.append(angle)
    
    # 计算平均角度
    if angles:
        return np.median(angles)
    else:
        return 0

6. 总结

通过这篇文章,我们实现了一个完整的文档扫描与旋转校正系统。从边缘检测、透视变换到旋转校正和图像增强,每个步骤都有详细的代码实现和解释。

实际使用中,你可能需要根据具体的文档类型和拍摄条件调整参数。比如,对于白底黑字的文档,二值化效果会很好;但对于彩色背景或者有图片的文档,可能需要更复杂的处理方法。

这个方案的优势在于完全开源、可定制,而且处理速度很快。你可以把它集成到自己的项目中,或者根据需求进行扩展,比如添加OCR文字识别功能,实现从图像到可编辑文本的完整流程。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐