📌 前言
在日常办公自动化、RPA(机器人流程自动化)或文档处理场景中,我们经常需要处理 PDF 文件。比如:自动在合同最后一页寻找最大空白处盖章 、在电子发票空白处添加水印 ,或者分析文档排版。
想要找到 PDF 中"最大的空白矩形",靠简单的像素遍历是非常低效的。本文将结合 PyMuPDF (fitz) 和 OpenCV ,利用经典的**"单调栈算法(求最大矩形)"**,实现高效且精准的 PDF 空白区域检测。
🛠️ 核心技术栈
-
PyMuPDF (fitz): 快速将 PDF 页面渲染为无损图像。
-
OpenCV (cv2): 图像灰度化、二值化、降采样与图形绘制。
-
Numpy: 矩阵运算与数据统计。
-
算法: 基于单调栈的二维矩阵最大矩形搜索(LeetCode 85 题变种)。
💡 算法核心原理解析
直接在全分辨率(如 150 DPI,约 1240x1754 像素)的图像上寻找最大矩形计算量极大。我们采取了以下策略:
-
降采样加速 :将页面缩放到宽度 1000 的掩码图。使用
INTER_AREA插值,只要降采样块内有暗色像素,就视为被占用,确保不遗漏细节。 -
生成占用/空白掩码 :通过阈值(默认 245)将图像二值化。
1代表空白,0代表被文字/线条占用。 -
单调栈寻找最大矩形 :将二维矩阵转化为"直方图",利用单调栈在 O(M×N)O(M×N) 的时间复杂度内找到掩码中面积最大的全
1矩形。 -
原图边界收缩修正 :因为降采样和抗锯齿影响,找到的矩形边缘可能会触碰到文字。映射回原图后,通过
while循环逐步向内收缩边界,直到原图 ROI 区域确实纯白(全满足阈值),保证绝对安全。 -
寻找 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)