最近做了一个小工具:批量去除 PDF 文件中的红色印章。项目目录很简单:
text
.
├── input
│ ├── 含印章文档1.pdf
│ └── 含有印章文档.pdf
├── output
│ ├── 含印章文档1.pdf
│ └── 含有印章文档.pdf
└── remove_red_seal.py
最初的脚本思路也很直接:把 PDF 每一页渲染成图片,识别红色像素,再用 OpenCV 的 inpaint 做图像修复,最后把图片重新合成 PDF。
这个方法能跑,但效果不够理想。它最大的问题不是"红色识别阈值不够准",而是处理方向一开始就选错了:很多 PDF 并不是一张扫描图,而是由文字、图片、矢量图形、透明层等对象组合出来的版面。把整页转成图片再修补,相当于先主动损失了 PDF 的结构信息。
原方案的问题
原始方案大致是这样的:
python
def remove_stamp_from_image(img):
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
lower_red = np.array([0, 43, 46])
upper_red = np.array([10, 255, 255])
mask = cv2.inRange(hsv, lower_red, upper_red)
result = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)
return result
然后对 PDF 页面逐页渲染、处理、保存。
这个思路适合纯扫描件,但对本项目里的样例并不是最优解。它有几个典型问题:
- 原本可选中的 PDF 文字会变成图片。
- 页面清晰度取决于渲染 DPI,容易变糊。
- 红章边缘的半透明像素可能残留。
- 图像修复只是"猜背景",无法真正恢复被覆盖的内容。
- 如果页面里有红色正文、批注或图标,容易误伤。
所以这次改造的核心目标是:不要先把 PDF 栅格化,而是先看印章在 PDF 里到底是什么。
关键发现:印章是独立图片对象
分析 input 目录下的两份样例后发现,它们都不是纯扫描件。正文是 PDF 文字对象,红色印章是单独插入的图片 XObject。
用 pdfplumber 检查第一页对象数量,可以看到:
text
input
含印章文档1.pdf pages=1 chars=451 images=1
含有印章文档.pdf pages=1 chars=562 images=1
output
含印章文档1.pdf pages=1 chars=451 images=0
含有印章文档.pdf pages=1 chars=562 images=0
这个结果非常关键。它说明印章不是和正文融合在一张大图里,而是一个独立图片。既然如此,最佳方案就不是修图,而是删除这个图片对象的绘制。
在 PDF 内容流里,这类图片通常会以类似下面的形式出现:
text
q
260 0 0 360 168 241 cm
/Im1 Do
Q
其中 /Im1 Do 表示绘制名为 Im1 的图片对象,外面的 q ... Q 是图形状态保存和恢复。只要确认 Im1 是红章,就可以删除这一段绘制指令,PDF 页面上的印章就消失了。
新方案:优先对象层删除,后备内容流重写
改造后的 remove_red_seal.py 使用了两层策略。
第一层是 PyMuPDF 对象删除。如果环境安装了 fitz/PyMuPDF,脚本会直接读取页面图片对象,对图片做红色特征检测。判断为红章后,通过 page.delete_image(xref) 删除对象,并用 garbage=4, deflate=True, clean=True 保存。
核心流程是:
python
for page in doc:
for image in page.get_images(full=True):
xref = image[0]
pix = fitz.Pixmap(doc, xref)
# 转为 RGB / BGR 后判断是否像红色印章
if _looks_like_red_stamp(bgr):
page.delete_image(xref)
第二层是纯 Python 后备方案。当前项目运行环境里没有安装 fitz,所以实际执行时走的是这个后备路径:
text
compact stream patch, removed 34 byte(s); PyMuPDF unavailable: No module named 'fitz'
compact stream patch, removed 55 byte(s); PyMuPDF unavailable: No module named 'fitz'
后备方案做了几件事:
- 解析 PDF 对象,找到
/Subtype /Image的图片对象。 - 解码图片流,支持
DCTDecode和常见的FlateDecodeRGB 图片。 - 判断图片是否符合红色印章特征。
- 根据
/XObject << /Im1 11 0 R >>这样的资源表,找到红章图片对应的绘制名。 - 解压页面内容流,删除
q ... /Im1 Do ... Q这一段绘制命令。 - 重写一个紧凑 PDF,替换内容流,同时把红章图片对象替换为 1x1 白色占位图。
这里有一个细节:一开始只做增量更新时,阅读器看到的页面已经没有印章,但旧图片字节仍可能残留在原 PDF 的历史数据里。后来改成了紧凑重写输出,避免"视觉上删除了,但文件里还藏着原图"的问题。
红章识别:不是只看红色像素
为了避免误删普通图片里的少量红色元素,脚本没有只用一个 HSV 阈值,而是使用 RGB 通道关系做判断:
python
return (
(r16 > 70)
& (r16 > g16 + 18)
& (r16 > b16 + 18)
& (r16 > (g16 * 1.12))
& (r16 > (b16 * 1.12))
)
然后再结合两个指标:
- 红色像素数量至少达到
200。 - 红色像素比例不低于
1%。 - 图片整体红色通道相对蓝绿通道有明显优势。
这个判断不追求识别所有红色物体,而是服务于一个明确目标:识别"像公章一样的红色图片对象"。范围收窄后,误删风险会明显降低。
为什么输出质量更好
对象层删除和图像修复最大的区别,是它不会动正文。
本项目两份样例处理后的验证结果是:
text
含印章文档1.pdf
输入:chars=451 images=1
输出:chars=451 images=0
含有印章文档.pdf
输入:chars=562 images=1
输出:chars=562 images=0
也就是说,正文字符数量保持不变,图片对象被移除。输出文件里也不再包含 /Im1 Do 绘制命令和原 JPEG 印章标记。
这比整页转图片再修复更适合电子 PDF:
- 文字仍然是 PDF 文字,不会变糊。
- 文件体积更小。
- 不会产生涂抹痕迹。
- 不会误修正文背景。
- 对红章边缘、透明度、抗锯齿都更干净。
扫描件怎么办
当然,这个方案并不是万能的。如果印章已经和扫描图融合成同一张大图,就没有独立的 /Im1 Do 可以删除。这时才应该进入图像处理流程:
- 高 DPI 渲染页面。
- 使用 HSV + RGB 联合阈值提取红色区域。
- 对 mask 做膨胀、闭运算,覆盖印章边缘。
- 使用
cv2.inpaint修复。 - 尽量保护黑色文字和表格线。
但扫描件方案有一个天然上限:如果红章盖住了文字,算法只能根据周围像素猜测背景,并不能真正还原原始文字。因此在工程实现里,推荐的顺序应该是:
text
PDF 对象层删除 -> 内容流修补 -> 图像级修复 -> 人工复核
而不是一上来就做像素级修复。
小结
这次改造最大的收获是:PDF 去印章不是一个单纯的图像处理问题,而是一个 PDF 结构分析问题。
如果印章是独立对象,删除对象就是最干净的方案;如果印章已经融合进扫描图,再考虑 OpenCV 修复。把这两个层次分清楚,效果会比单纯调阈值好很多。
当前项目的脚本已经实现了这个思路:
- 有 PyMuPDF 时,优先使用对象级删除。
- 没有 PyMuPDF 时,使用纯 Python 内容流重写后备方案。
- 批量读取
input目录 PDF,输出到output目录。 - 对本项目样例,正文字符数保持不变,印章图片对象被移除。
从"把红色涂掉"转向"理解 PDF 后删除对象",是这类问题从能用到好用的关键一步。
python包依赖:
shell
pip install pymupdf opencv-python numpy pillow
源码:
python
#!/usr/bin/env python3
"""
Remove red seal / stamp images from PDFs in input/ and write clean PDFs to output/.
Preferred path:
- PyMuPDF removes the stamp image object from the PDF page, preserving vector text.
Fallback path:
- For simple PDFs like the samples in this project, patch the PDF content stream
incrementally and remove only the drawing commands for red image XObjects.
Optional install for the preferred path:
pip install pymupdf opencv-python numpy pillow
"""
from __future__ import annotations
import os
import re
import shutil
import zlib
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
INPUT_FOLDER = Path("./input")
OUTPUT_FOLDER = Path("./output")
@dataclass(frozen=True)
class PdfObject:
number: int
body: bytes
def _red_mask_bgr(img: np.ndarray) -> np.ndarray:
"""Segment red seal ink, including pale anti-aliased edge pixels."""
if img.ndim == 2:
return np.zeros(img.shape, dtype=bool)
b, g, r = cv2.split(img[:, :, :3])
r16 = r.astype(np.int16)
g16 = g.astype(np.int16)
b16 = b.astype(np.int16)
return (
(r16 > 70)
& (r16 > g16 + 18)
& (r16 > b16 + 18)
& (r16 > (g16 * 1.12))
& (r16 > (b16 * 1.12))
)
def _looks_like_red_stamp(img: np.ndarray) -> bool:
mask = _red_mask_bgr(img)
red_pixels = int(mask.sum())
red_ratio = red_pixels / max(mask.size, 1)
if red_pixels < 200:
return False
bgr_mean = img[:, :, :3].mean(axis=(0, 1))
red_dominance = float(bgr_mean[2] - max(bgr_mean[0], bgr_mean[1]))
return red_ratio >= 0.01 and red_dominance >= 4
def _remove_with_pymupdf(src: Path, dst: Path) -> int:
import fitz # type: ignore
doc = fitz.open(src)
removed = 0
for page in doc:
for image in page.get_images(full=True):
xref = image[0]
pix = fitz.Pixmap(doc, xref)
if pix.n >= 4:
pix = fitz.Pixmap(fitz.csRGB, pix)
channels = pix.n
samples = np.frombuffer(pix.samples, dtype=np.uint8)
try:
arr = samples.reshape(pix.h, pix.w, channels)
except ValueError:
continue
rgb = arr[:, :, :3]
bgr = rgb[:, :, ::-1].copy()
if _looks_like_red_stamp(bgr):
page.delete_image(xref)
removed += 1
if removed:
doc.save(dst, garbage=4, deflate=True, clean=True)
else:
shutil.copyfile(src, dst)
doc.close()
return removed
def _iter_pdf_objects(data: bytes) -> list[PdfObject]:
matches = list(re.finditer(rb"(?m)^(\d+)\s+0\s+obj\b", data))
objects: list[PdfObject] = []
for i, match in enumerate(matches):
start = match.end()
end = data.find(b"endobj", start)
if end < 0:
next_start = matches[i + 1].start() if i + 1 < len(matches) else len(data)
end = next_start
objects.append(PdfObject(int(match.group(1)), data[start:end]))
return objects
def _stream_parts(body: bytes) -> tuple[bytes, bytes] | None:
stream_pos = body.find(b"stream")
if stream_pos < 0:
return None
data_start = stream_pos + len(b"stream")
if body[data_start : data_start + 2] == b"\r\n":
data_start += 2
elif body[data_start : data_start + 1] == b"\n":
data_start += 1
data_end = body.find(b"endstream", data_start)
if data_end < 0:
return None
raw = body[data_start:data_end]
while raw.endswith((b"\r", b"\n")):
raw = raw[:-1]
return body[:stream_pos], raw
def _decode_image_stream(header: bytes, raw: bytes) -> np.ndarray | None:
if b"/ColorSpace /DeviceGray" in header:
return None
if b"/DCTDecode" in header:
return cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if b"/FlateDecode" not in header:
return None
width_match = re.search(rb"/Width\s+(\d+)", header)
height_match = re.search(rb"/Height\s+(\d+)", header)
bits_match = re.search(rb"/BitsPerComponent\s+(\d+)", header)
if not width_match or not height_match or bits_match and bits_match.group(1) != b"8":
return None
width = int(width_match.group(1))
height = int(height_match.group(1))
try:
decoded = zlib.decompress(raw)
except zlib.error:
return None
if len(decoded) < width * height * 3:
return None
rgb = np.frombuffer(decoded[: width * height * 3], np.uint8).reshape(height, width, 3)
return rgb[:, :, ::-1].copy()
def _red_image_object_numbers(objects: list[PdfObject]) -> set[int]:
red_images: set[int] = set()
for obj in objects:
if b"/Subtype" not in obj.body or b"/Image" not in obj.body:
continue
parts = _stream_parts(obj.body)
if not parts:
continue
header, raw = parts
img = _decode_image_stream(header, raw)
if img is not None and _looks_like_red_stamp(img):
red_images.add(obj.number)
return red_images
def _xref_image_names(objects: list[PdfObject], red_images: set[int]) -> set[bytes]:
names: set[bytes] = set()
for obj in objects:
for block in re.findall(rb"/XObject\s*<<(.*?)>>", obj.body, flags=re.S):
for name, ref in re.findall(rb"/([A-Za-z0-9_.-]+)\s+(\d+)\s+0\s+R", block):
if int(ref) in red_images:
names.add(name)
return names
def _remove_image_draws(content: bytes, image_names: set[bytes]) -> bytes:
cleaned = content
for name in sorted(image_names, key=len, reverse=True):
escaped = re.escape(name)
# Matches the balanced graphics-state wrapper typically emitted for
# image XObjects: q <matrix> cm /ImN Do Q
pattern = rb"q\b(?:(?!\bq\b|\bQ\b).)*?/" + escaped + rb"\s+Do\s+Q"
cleaned = re.sub(pattern, b"", cleaned, flags=re.S)
# Safety net for uncommon producers that draw the image without q/Q.
cleaned = re.sub(rb"/" + escaped + rb"\s+Do\b", b"", cleaned)
return cleaned
def _trailer_value(data: bytes, key: bytes) -> bytes | None:
trailer_pos = data.rfind(b"trailer")
if trailer_pos < 0:
return None
trailer = data[trailer_pos:]
match = re.search(rb"/" + key + rb"\s+(\[[^\]]+\]|\d+\s+\d+\s+R|\d+)", trailer, re.S)
return match.group(1) if match else None
def _minimal_image_body() -> bytes:
return (
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 "
b"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>\n"
b"stream\n"
b"\xff\xff\xff\n"
b"endstream"
)
def _compact_rewrite_pdf(
src_data: bytes,
objects: list[PdfObject],
stream_replacements: dict[int, bytes],
image_replacements: set[int],
) -> bytes:
size_value = _trailer_value(src_data, b"Size")
root_value = _trailer_value(src_data, b"Root")
info_value = _trailer_value(src_data, b"Info")
id_value = _trailer_value(src_data, b"ID")
if not size_value or not root_value:
raise RuntimeError("Cannot read PDF trailer.")
max_object = max([obj.number for obj in objects] + [int(size_value) - 1])
output = bytearray(b"%PDF-1.4\n")
offsets: dict[int, int] = {}
for obj in sorted(objects, key=lambda item: item.number):
body = obj.body.strip()
if obj.number in stream_replacements:
compressed = zlib.compress(stream_replacements[obj.number], level=9)
body = (
f"<< /Filter /FlateDecode /Length {len(compressed)} >>\n".encode()
+ b"stream\n"
+ compressed
+ b"\nendstream"
)
elif obj.number in image_replacements:
body = _minimal_image_body()
offsets[obj.number] = len(output)
output.extend(f"{obj.number} 0 obj\n".encode())
output.extend(body)
output.extend(b"\nendobj\n")
xref_offset = len(output)
output.extend(f"xref\n0 {max_object + 1}\n".encode())
output.extend(b"0000000000 65535 f \n")
for obj_no in range(1, max_object + 1):
if obj_no in offsets:
output.extend(f"{offsets[obj_no]:010d} 00000 n \n".encode())
else:
output.extend(b"0000000000 65535 f \n")
trailer = b"trailer\n<< "
trailer += b"/Size " + str(max_object + 1).encode() + b" "
trailer += b"/Root " + root_value + b" "
if info_value:
trailer += b"/Info " + info_value + b" "
if id_value:
trailer += b"/ID " + id_value + b" "
trailer += b">>\n"
trailer += b"startxref\n" + str(xref_offset).encode() + b"\n%%EOF\n"
output.extend(trailer)
return bytes(output)
def _remove_with_stream_patch(src: Path, dst: Path) -> int:
data = src.read_bytes()
objects = _iter_pdf_objects(data)
red_images = _red_image_object_numbers(objects)
image_names = _xref_image_names(objects, red_images)
replacements: dict[int, bytes] = {}
removed = 0
if not image_names:
shutil.copyfile(src, dst)
return 0
for obj in objects:
parts = _stream_parts(obj.body)
if not parts or b"/FlateDecode" not in parts[0]:
continue
try:
content = zlib.decompress(parts[1])
except zlib.error:
continue
cleaned = _remove_image_draws(content, image_names)
if cleaned != content:
replacements[obj.number] = cleaned
removed += len(content) - len(cleaned)
if replacements:
dst.write_bytes(_compact_rewrite_pdf(data, objects, replacements, red_images))
else:
shutil.copyfile(src, dst)
return removed
def remove_red_seal_from_pdf(src: Path, dst: Path) -> str:
try:
removed = _remove_with_pymupdf(src, dst)
return f"PyMuPDF object removal, removed {removed} image(s)"
except Exception as exc:
removed = _remove_with_stream_patch(src, dst)
return f"compact stream patch, removed {removed} byte(s); PyMuPDF unavailable: {exc}"
def main() -> None:
OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
pdfs = sorted(INPUT_FOLDER.glob("*.pdf"))
if not pdfs:
print(f"No PDF files found in {INPUT_FOLDER}")
return
for src in pdfs:
dst = OUTPUT_FOLDER / src.name
mode = remove_red_seal_from_pdf(src, dst)
print(f"{src.name}: {mode} -> {dst}")
if __name__ == "__main__":
main()