Python实战:基于OpenCV与单调栈,精准定位PDF中最大的空白区域

📌 前言

在日常办公自动化、RPA(机器人流程自动化)或文档处理场景中,我们经常需要处理 PDF 文件。比如:自动在合同最后一页寻找最大空白处盖章 、在电子发票空白处添加水印 ,或者分析文档排版。

想要找到 PDF 中"最大的空白矩形",靠简单的像素遍历是非常低效的。本文将结合 PyMuPDF (fitz) 和 OpenCV ,利用经典的**"单调栈算法(求最大矩形)"**,实现高效且精准的 PDF 空白区域检测。

🛠️ 核心技术栈

  • PyMuPDF (fitz): 快速将 PDF 页面渲染为无损图像。

  • OpenCV (cv2): 图像灰度化、二值化、降采样与图形绘制。

  • Numpy: 矩阵运算与数据统计。

  • 算法: 基于单调栈的二维矩阵最大矩形搜索(LeetCode 85 题变种)。

💡 算法核心原理解析

直接在全分辨率(如 150 DPI,约 1240x1754 像素)的图像上寻找最大矩形计算量极大。我们采取了以下策略:

  1. 降采样加速 :将页面缩放到宽度 1000 的掩码图。使用 INTER_AREA 插值,只要降采样块内有暗色像素,就视为被占用,确保不遗漏细节。

  2. 生成占用/空白掩码 :通过阈值(默认 245)将图像二值化。1 代表空白,0 代表被文字/线条占用。

  3. 单调栈寻找最大矩形 :将二维矩阵转化为"直方图",利用单调栈在 O(M×N)O(M×N) 的时间复杂度内找到掩码中面积最大的全 1 矩形。

  4. 原图边界收缩修正 :因为降采样和抗锯齿影响,找到的矩形边缘可能会触碰到文字。映射回原图后,通过 while 循环逐步向内收缩边界,直到原图 ROI 区域确实纯白(全满足阈值),保证绝对安全。

  5. 寻找 Top N:找到最大矩形后,在掩码矩阵中将其涂黑(标记为占用),然后继续寻找下一个最大矩形,循环 5 次即可得到前 5 大空白区域。

💻 完整代码实现

以下是完整的 Python 实现代码,不仅包含了精准的检测逻辑,还封装了极为健壮的图片保存函数(避免中文路径报错或 OpenCV 编码失败)。

复制代码
import os
import cv2
import fitz
import numpy as np
import traceback


def find_max_rectangle(binary_matrix):
    """
    查找二值矩阵中最大矩形。
    binary_matrix: 1=空白,0=非空白。
    返回: (area, (top, left, bottom, right))
    坐标采用左闭右开区间。
    """
    matrix = np.asarray(binary_matrix, dtype=np.uint8)
    if matrix.ndim != 2 or matrix.size == 0:
        return 0, (0, 0, 0, 0)

    rows, cols = matrix.shape
    heights = np.zeros(cols, dtype=np.int32)
    max_area = 0
    max_rect = (0, 0, 0, 0)

    for i in range(rows):
        heights = np.where(matrix[i] == 1, heights + 1, 0)
        stack = []

        for j in range(cols + 1):
            cur_height = int(heights[j]) if j < cols else 0
            while stack and int(heights[stack[-1]]) > cur_height:
                idx = stack.pop()
                h = int(heights[idx])
                left = stack[-1] + 1 if stack else 0
                right = j
                area = h * (right - left)

                if area > max_area:
                    max_area = area
                    max_rect = (i - h + 1, left, i + 1, right)

            stack.append(j)

    return max_area, max_rect


def pdf_page_to_image(page, dpi=150):
    """将 PDF 页面渲染为 BGR 图像。"""
    pix = page.get_pixmap(dpi=dpi, colorspace=fitz.csRGB, alpha=False)
    image = np.frombuffer(pix.samples, dtype=np.uint8)
    image = image.reshape(pix.height, pix.width, pix.n)
    return cv2.cvtColor(image, cv2.COLOR_RGB2BGR)


def build_empty_mask(image, search_width=1000, threshold=245):
    """
    生成用于搜索的低分辨率空白掩码。
    1=空白,0=非空白。
    将任何检测到的暗色像素保守地视为占用内容。
    """
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    occupied = (gray < threshold).astype(np.uint8)

    h, w = occupied.shape
    if w > search_width:
        new_w = search_width
        new_h = max(1, round(h * new_w / w))

        # 对占用掩码做面积缩小;只要缩小块内有占用像素,就标为占用。
        small = cv2.resize(
            occupied.astype(np.float32),
            (new_w, new_h),
            interpolation=cv2.INTER_AREA
        )
        occupied = (small > 0).astype(np.uint8)

    empty = 1 - occupied
    return empty


def detect_largest_blank_rectangle(image, search_width=1000, threshold=245):
    """检测图像中的最大空白矩形,返回原图坐标。"""
    empty_mask = build_empty_mask(image, search_width, threshold)
    area, rect = find_max_rectangle(empty_mask)

    top, left, bottom, right = rect
    mask_h, mask_w = empty_mask.shape
    image_h, image_w = image.shape[:2]

    # 将搜索掩码坐标映射回原图坐标。
    x_scale = image_w / mask_w
    y_scale = image_h / mask_h

    x1 = int(np.ceil(left * x_scale))
    y1 = int(np.ceil(top * y_scale))
    x2 = int(np.floor(right * x_scale))
    y2 = int(np.floor(bottom * y_scale))

    # 再用原始分辨率检查候选矩形,避免降采样遗漏细线。
    # 不满足条件时逐步收缩边界,得到保守的空白矩形。
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    while x2 > x1 and y2 > y1:
        roi = gray[y1:y2, x1:x2]
        if roi.size and np.all(roi >= threshold):
            break

        if (x2 - x1) >= (y2 - y1):
            x1 += 1
            x2 -= 1
        else:
            y1 += 1
            y2 -= 1

    if x2 <= x1 or y2 <= y1:
        return 0, None
    return (x2 - x1) * (y2 - y1), (x1, y1, x2, y2)


def detect_top_blank_rectangles(image, top_n=5, search_width=1000, threshold=245):
    """
    查找图像中面积最大的前 top_n 个空白矩形。
    返回 [(area, (x1, y1, x2, y2)), ...]
    坐标为原图像素坐标,右下角采用左闭右开区间。
    """
    empty_mask = build_empty_mask(image, search_width, threshold)
    mask_h, mask_w = empty_mask.shape
    image_h, image_w = image.shape[:2]

    x_scale = image_w / mask_w
    y_scale = image_h / mask_h
    results = []

    for _ in range(top_n):
        area, (top, left, bottom, right) = find_max_rectangle(empty_mask)
        if area <= 0:
            break

        x1 = int(np.ceil(left * x_scale))
        y1 = int(np.ceil(top * y_scale))
        x2 = int(np.floor(right * x_scale))
        y2 = int(np.floor(bottom * y_scale))

        # 验证原图区域是否完全空白
        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
        while x2 > x1 and y2 > y1:
            roi = gray[y1:y2, x1:x2]
            if roi.size and np.all(roi >= threshold):
                break
            if (x2 - x1) >= (y2 - y1):
                x1 += 1
                x2 -= 1
            else:
                y1 += 1
                y2 -= 1

        if x2 <= x1 or y2 <= y1:
            # 排除无法验证的候选区域,避免死循环
            empty_mask[top:bottom, left:right] = 0
            continue

        results.append(((x2 - x1) * (y2 - y1), (x1, y1, x2, y2)))

        # 将本次找到的矩形从候选空白区域中排除
        empty_mask[top:bottom, left:right] = 0

    return results


def save_image_debug(save_path, image):
    save_path = os.path.abspath(save_path)
    output_dir = os.path.dirname(save_path)
    os.makedirs(output_dir, exist_ok=True)

    print(f"\n保存路径: {save_path}")
    print(f"目录存在: {os.path.isdir(output_dir)}")
    print(f"目录可写: {os.access(output_dir, os.W_OK)}")
    print(f"图片信息: shape={image.shape}, dtype={image.dtype}")

    # 1. 先尝试 OpenCV
    try:
        ok = cv2.imwrite(save_path, image)
        print(f"cv2.imwrite 返回: {ok}")
        if ok and os.path.isfile(save_path):
            print(f"保存成功,文件大小: {os.path.getsize(save_path)} 字节")
            return True
    except Exception:
        traceback.print_exc()

    # 2. 使用 Pillow 作为备用方案
    try:
        from PIL import Image
        image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        Image.fromarray(image_rgb).save(save_path, format="PNG")
        if os.path.isfile(save_path) and os.path.getsize(save_path) > 0:
            print(f"Pillow 保存成功,文件大小: {os.path.getsize(save_path)} 字节")
            return True
    except Exception:
        traceback.print_exc()

    # 3. 检查文件是否真的能够写入
    try:
        test_path = os.path.join(output_dir, "_write_test.tmp")
        with open(test_path, "wb") as f:
            f.write(b"test")
        os.remove(test_path)
        print("目录写入测试成功,但图片编码或保存失败。")
    except Exception:
        traceback.print_exc()

    print("图片保存失败,请根据上面的异常继续定位。")
    return False


def PPStructureV3_table(pdf_path, dpi=150, search_width=1000):
    """逐页检测 PDF 中最大的可见空白矩形。"""
    output_dir = os.path.join(
        os.path.dirname(pdf_path),
        os.path.splitext(os.path.basename(pdf_path))[0],
        "images"
    )
    os.makedirs(output_dir, exist_ok=True)

    with fitz.open(pdf_path) as doc:
        for page_num, page in enumerate(doc):
            image = pdf_page_to_image(page, dpi=dpi)
            # 只查找最大空白区域
            # area, rect = detect_largest_blank_rectangle(image,search_width=search_width)
            # if rect is not None:
            #     x1, y1, x2, y2 = rect
            #     print(f"页码:{page_num + 1}, " f"面积:{area}, " f"位置:{rect}, "  f"宽:{x2 - x1}, 高:{y2 - y1}")
            #     cv2.rectangle(image, (x1, y1), (x2 - 1, y2 - 1), (0, 0, 255), 3)
            # else:
            #     print(f"页码:{page_num + 1}: 未找到有效空白矩形")

            # 返回最大的矩形空白区域前5个
            rectangles = detect_top_blank_rectangles(image, top_n=5)
            for rank, (area, rect) in enumerate(rectangles, 1):
                x1, y1, x2, y2 = rect
                print(f"第{rank}个空白矩形: 面积={area}, 位置={rect}, 宽={x2 - x1}, 高={y2 - y1}")
                # 绘制矩形边框
                R = int(150 + 105 * rank)  # 150 -> 255
                G = int(50 - 50 * rank)  # 50 -> 0
                B = int(50)
                cv2.rectangle(image, (x1, y1), (x2 - 1, y2 - 1), (B, G, R), 2)

                # 绘制序号标签
                label = str(rank)
                font = cv2.FONT_HERSHEY_SIMPLEX
                font_scale = 0.8
                thickness = 2
                (text_w, text_h), baseline = cv2.getTextSize(label, font, font_scale, thickness)

                # 标签放在矩形左上角,尽量避免超出图片边界
                label_x = max(0, x1)
                label_y = max(text_h + baseline + 4, y1)

                # 序号背景框(红色背景,白色文字)
                cv2.rectangle(image, (label_x, label_y - text_h - baseline - 4),
                    (label_x + text_w + 10, label_y + 3), (0, 0, 255), -1)

                # 绘制白色序号
                cv2.putText(image, label, (label_x + 5, label_y - baseline), font, font_scale, (255, 255, 255), thickness, cv2.LINE_AA)

            # 显示结果
            print('显示结果')
            # resized_image = cv2.resize(image, (int(image.shape[1] * 0.6), int(image.shape[0] * 0.6)))
            # cv2.imshow('Max Rectangle', resized_image)
            # cv2.waitKey(0)
            # cv2.destroyAllWindows()

            # 保存成图片
            save_path = os.path.join(output_dir, f"page_{page_num + 1}.png")
            save_image_debug(save_path, image)
            print('*'*100)


if __name__ == "__main__":
    path_list = r"C:\Users\YHCX\Desktop\files\pdf_bank"
    for root, _, filenames in os.walk(path_list):
        for filename in filenames:
            if not filename.lower().endswith(".pdf") or filename.startswith("~$"):
                continue
            pdf_path = os.path.join(root, filename)
            print(f"正在处理:{pdf_path}")
            PPStructureV3_table(pdf_path)
相关推荐
terry6001 小时前
企业数据查询接口防爬实战:图形验证码服务端校验接入与限流设计(附Python)
python·信息与通信·图形验证码·反爬虫·人机验证·行为验证码·滑动拼图验证
量化炼金 (CodeAlchemy)1 小时前
【Python】pandas vs polars 简单性能对比测试
开发语言·python·pandas
学习论之费曼学习法1 小时前
无人值守AI内容矩阵实战(7):Playwright 发布器避坑——编辑器竞态、索引延迟与风控
python·线性代数·矩阵·编辑器·浏览器自动化·playwright·踩坑记录
wuyk5551 小时前
PyQt6-第06讲-主窗口框架
python·pyqt
ss.li1 小时前
rppg相关论文阅读笔记10.22
论文阅读·笔记·python
tryCbest1 小时前
Python之Flask项目部署到阿里云
python·阿里云·flask
liwulin05062 小时前
【PYTORCH】迷你DECODER-ONLY模型
pytorch·python·深度学习
全栈练习生2 小时前
Agent工程化三层架构
python·ai
高洁012 小时前
边缘AI与大模型轻量化:让智能跑进每一个终端
人工智能·python·深度学习·机器学习·迁移学习