1.1 清印 ClearMark — 一款本地文档去水印工作台的完整设计与实现

1.1 清印 ClearMark --- 一款本地文档去水印工作台的完整设计与实现

系列第 1 篇 · 共 12 篇

这不是一篇产品软文,而是一名一线开发者对自己做过的一个工具系统的复盘。从产品定位、架构选型,到 PDF 内容流解析、扫描件像素级水印检测、OpenCV 图像修复、PySide6 桌面 UI、跨平台适配、批量任务编排------我会把整套设计拆成 12 篇,逐步讲清楚。

如果你只是想要个能用的工具,可以直接拉到文末下载链接;如果你想知道"水印到底是怎么被检测和擦除的",欢迎跟着系列一路读下去。

一、为什么我们要再做一款去水印工具

市面上并不缺去水印工具。在线版的有 ILovePDF、SmallPDF,桌面端的有 WPS 自带的去水印、Adobe Acrobat 的编辑功能,还有一堆 PDF 转换类网站顺手提供。但真到了实际生产环境,你会发现它们都卡在三类痛点上:

痛点 1:在线工具过不了合规审查

金融、政企、科研单位里,文档大多含合同条款、客户数据、内部资料。把这些文件传到一个境外服务器去做"水印识别",合规这一关根本过不去。哪怕服务方承诺"处理完即删",审计也写不出来。

我自己就遇到过:一份扫描件采购合同,上面盖了"仅供内部使用"的灰色斜铺水印,业务方想拿去做汇报。问了一圈在线工具,没有一个敢传。最后只能截图------截图又把 OCR 识别率拉低了 30%。

痛点 2:通用工具识别不全

WPS 的去水印只认页眉页脚里的几个固定模板;Acrobat 的"编辑 PDF"可以删文字,但你得手动一个个点;Photoshop 能修图,但不能直接处理 PDF 内容流,而且对扫描件烤进去的浅色斜字根本无能为力------那是像素,不是图层。

最让人头疼的是扫描件。很多公司用扫描全能王、福昕、WPS 扫描后导出 PDF,水印是 烤进扫描图里 的------文字被光栅化成像素,浅灰色、半透明、45° 斜铺,整页覆盖。这种水印既不是 PDF 注释,也不是 XObject,是图片像素本身。绝大多数工具遇到它只能放弃。

痛点 3:批量处理无从下手

实际场景里没人会一次处理一份。法务一周要清理几十份合同,研究院要批量脱敏几百页报告。这类需求需要:

  • 一次拖入整个文件夹
  • 并发检测,不要逐个等
  • 每份文件单独列出候选,可以人工勾选
  • 失败的不影响其他文件
  • 输出有迹可循,不覆盖原文件

市面上 99% 的工具只支持单文件、单水印、单点删除。批量?不存在的。

二、清印 ClearMark 是什么

基于上述痛点,我们做了 清印 ClearMark 智能文档去水印工作台 V2.0。一句话定位:

一款 100% 本地运行、支持 PDF 与图片、覆盖矢量/扫描/混合三类水印、可批量的桌面去水印工具。

它的核心约束有三条:

  1. 不联网。所有解析、检测、修复、保存都在本地完成。源文件不上传任何服务器,连个 ping 都没有。
  2. 不动源文件 。输出永远是新文件,存到源目录的 output/ 子文件夹;不可写时回退到 ~/.clearmark/output/;同时源文件打开时自动备份到 backup/。
  3. 可解释。每条检测到的水印都会列出候选,标注置信度、坐标、来源(重复/旋转/签名/聚类/OCR),用户勾选才移除------不做"黑盒一键清除"。

它能处理什么水印

经过两轮迭代,V2.0 覆盖的水印类型如下:

大类 子类 检测方式 移除策略
矢量 PDF 文字水印 跨页重复、同页平铺网格、旋转大字、品牌签名 内容流解析 + 几何特征 + 规则库关键词 内容流区间擦除
矢量 PDF 图片水印 Logo、二维码、共享 XObject XObject xref 跨页统计 整对象清空(空流 + GC)
矢量 PDF 图层水印 OCG 可选内容图层 doc.get_ocgs() 写入 /D/OFF
矢量 PDF 注释水印 Watermark/Stamp/FreeText 注释 page.annots() 遍历 page.delete_annot()
扫描件浅色斜铺文字 烤进扫描图的半透明斜字 像素级笔画分割 + 形态学分组 + 整页旋转 OCR 验证 邻域最大亮度填充 + 像素掩膜修复
扫描件颜色聚类层 灰色半透明覆盖 K-Means 颜色聚类 Alpha 反演修复
图片水印 文字、Logo、灰色覆盖层 OCR + 颜色聚类 + 用户涂抹 Telea 修复 + Alpha 反演
用户手动标记 矩形框选、画笔涂抹、套索 用户交互 矩形掩膜 + 修复

这张表是整个系列后续 10 篇文章的索引。每一行都对应一类检测器和一个移除算法,背后都有真实调试故事。

三、技术栈为什么这么选

工欲善其事,必先利其器。但选器这件事,比做事本身更费心。我们最终的技术栈是:

维度 选型 选它的理由
语言 Python 3.10.11 PDF/图像生态最完整,开发效率高,便于交付源码
GUI 框架 PySide6(Qt6 官方 Python 绑定) 跨平台一致、QPainter 渲染能力强、信号槽机制天然适配后台线程
PDF 解析 PyMuPDF(fitz) 同时支持内容流读写、XObject 操作、注释操作、页面渲染,API 比 pypdf 完整得多
图像处理 OpenCV + NumPy K-Means 聚类、形态学操作、Telea/NS 修复算法都是 OpenCV 原生支持
OCR 引擎 RapidOCR(PaddleOCR ONNX 版) 离线包可独立分发,不依赖 PaddlePaddle 大包,识别中文稳定
内容流解析 自研 content_stream.py PyMuPDF 提供的 get_texttrace 不暴露字节偏移,无法做区间擦除,必须自己写
打包 PyInstaller onedir 模式 绿色免安装,整个文件夹拷到目标机器即可运行
目标平台 银河麒麟 V10 / Windows 11 国产化适配 + 主流桌面

为什么不上 PaddleOCR 全量包?因为它体积大(700MB+),而 RapidOCR 只用 ONNX runtime 推理,模型 30MB,识别中文精度足够。这套组合在麒麟 V10 ARM64 上也能跑起来。

为什么不用 pdfplumber / pypdf?因为它们 不支持修改。去水印的本质是写操作------你要从内容流里删一段、要清空 XObject、要改 OCG 状态,这些都需要写权限。PyMuPDF 是少数能稳定支持 PDF 写操作的库。

为什么不用 PyQt 而用 PySide6?许可证。PySide6 是 LGPL,商用更友好;API 与 PyQt 几乎一致,迁移成本为零。

四、系统总览:三层架构

整个系统分为三层,每层职责清晰、互不耦合:

复制代码
┌─────────────────────────────────────────────────────────┐
│  UI 层(ui/)                                            │
│  ┌──────────────┬──────────────┬────────────────────┐   │
│  │ MainWindow   │ PreviewWidget│ ResultPanel        │   │
│  │ 三栏布局      │ QPainter渲染 │ 候选列表+勾选       │   │
│  ├──────────────┼──────────────┼────────────────────┤   │
│  │ Worker线程    │ Icons多分辨率│ Styles 主题         │   │
│  └──────────────┴──────────────┴────────────────────┘   │
└────────────────────────┬────────────────────────────────┘
                         │ 统一数据模型
┌────────────────────────┴────────────────────────────────┐
│  核心层(core/)                                         │
│  ┌──────────┬──────────┬──────────┬─────────────────┐  │
│  │ Session  │TaskOrch  │FormatProb│ ContentStream   │  │
│  │单文件会话 │批量编排   │格式路由   │ 内容流解析器     │  │
│  ├──────────┼──────────┼──────────┼─────────────────┤  │
│  │ Processor│ Detect/  │ Inpaint/ │ Rules/          │  │
│  │三类处理器 │ 5个检测器 │ CV修复引擎│ 规则库          │  │
│  └──────────┴──────────┴──────────┴─────────────────┘  │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────┴────────────────────────────────┐
│  数据层                                                   │
│  WatermarkCandidate · DetectionResult · TaskItem        │
│  OutputStrategy · UserMark                                │
└─────────────────────────────────────────────────────────┘

数据层:统一数据模型是关键

整个系统最关键的一个设计是 core/model.py 里的 WatermarkCandidate:

python 复制代码
@dataclass
class WatermarkCandidate:
    """检测到的水印候选

    统一表达所有格式的水印检测结果,UI 层只消费此结构。
    """
    kind: WatermarkKind                 # 水印种类
    page_index: int = 0
    bbox: Tuple[float, float, float, float] = (0, 0,  0, 0)
    origin: Tuple[float, float] = (0, 0)
    rotation: float = 0.0
    detail: str = ''
    confidence: float = 0.0
    confidence_level: ConfidenceLevel = ConfidenceLevel.NONE

    # 内容流相关(PDF 矢量用)
    start: int = -1                     # 内容流字节偏移
    end: int = -1
    xref: int = -1                      # XObject xref
    text: str = ''
    color: Optional[Tuple[float, float, float]] = None
    font_size: float = 0.0
    alpha: float = 1.0

    # 用户控制
    selected: bool = True

    # 网格信息(平铺水印用)
    grid_rows: int = 0
    grid_cols: int = 0
    grid_angle: float = 0.0

    # 运行时像素掩膜(扫描图/图片处理器用)
    mask: Optional[object] = None

它的精妙之处在于:所有格式的所有检测结果,都被统一表达成这一个结构 。PDF 矢量文字水印用 start/end 存内容流偏移;扫描件浅色斜铺水印用 mask 存像素掩膜;规则库命中用 text 存关键词;旋转大字水印用 rotation 存角度。UI 层(ResultPanel)只需要遍历 candidates 列表就能渲染所有候选,完全不需要知道背后是 PDF 还是图片。

这个数据结构是整个系统的"通用货币",后续 11 篇文章都会反复提到它。

处理器层:策略模式 + 工厂路由

core/format_probe.py 是入口:

python 复制代码
def probe_format(path: str) -> FormatType:
    """探测文件格式类型

    对于 PDF,进一步判断是矢量型、扫描型还是混合型。
    """
    ext = get_extension(path)
    if ext in PDF_EXTS:
        return _probe_pdf_type(path)
    elif ext in IMAGE_EXTS:
        return FormatType.IMAGE
    ...

def _probe_pdf_type(path: str) -> FormatType:
    """判断 PDF 类型:矢量 / 扫描 / 混合"""
    doc = fitz.open(path)
    has_text = False
    has_full_page_image = False
    has_vector = False
    check_pages = min(doc.page_count, 5)
    for i in range(check_pages):
        page = doc[i]
        text = page.get_text("text").strip()
        if text:
            has_text = True
        images = page.get_images(full=True)
        for img in images:
            xref = img[0]
            pix = fitz.Pixmap(doc, xref)
            # 图片面积接近页面面积 → 扫描件
            if pix.width * pix.height > page.rect.width * page.rect.height * 0.8:
                has_full_page_image = True
    doc.close()
    if has_full_page_image and not has_text:
        return FormatType.PDF_SCANNED
    elif has_full_page_image and has_text:
        return FormatType.PDF_MIXED
    else:
        return FormatType.PDF_VECTOR

注意这里有一个 三条腿走路 的策略:

  • 矢量 PDF (有文字指令、无大图)→ PdfVectorProcessor:走内容流解析路线,可无损擦除
  • 扫描 PDF (无文字指令、有整页大图)→ PdfScannedProcessor:走像素级修复路线
  • 混合 PDF(既有文字又有大图)→ 走矢量处理器的混合策略

路由器在 get_processor(format_type) 里完成实例化:

python 复制代码
def get_processor(format_type: FormatType):
    if format_type in (FormatType.PDF_VECTOR, FormatType.PDF_MIXED):
        from .processor.pdf_vector import PdfVectorProcessor
        return PdfVectorProcessor()
    elif format_type == FormatType.PDF_SCANNED:
        from .processor.pdf_scanned import PdfScannedProcessor
        return PdfScannedProcessor()
    elif format_type == FormatType.IMAGE:
        from .processor.image_inpaint import ImageInpaintProcessor
        return ImageInpaintProcessor()
    ...

所有处理器继承同一个抽象基类 IProcessor:

python 复制代码
class IProcessor(ABC):
    @abstractmethod
    def open(self, path: str): ...
    @abstractmethod
    def detect_auto(self) -> DetectionResult: ...
    @abstractmethod
    def detect_region(self, marks: List[UserMark]) -> DetectionResult: ...
    @abstractmethod
    def detect_preset(self, rule_name: str) -> DetectionResult: ...
    @abstractmethod
    def remove(self, candidates, output_path, progress_callback=None) -> int: ...
    @abstractmethod
    def render_page(self, page_index: int, zoom: float = 1.0) -> bytes: ...

UI 层和会话管理器只需要调 processor.detect_auto() / processor.remove(...),根本不需要知道背后是哪种格式。这套设计让后续增加新格式(比如 Word、PPT)只需要新增一个 IProcessor 子类,不改 UI 层一行代码。

五、三类处理器,三套坐标系

整个系列最绕的地方是 坐标契约。三类处理器用三套不同的坐标系:

处理器 坐标系 bbox 含义
PdfVectorProcessor PDF 页面点(page.rect) 文字块/图片块在页面上的位置
PdfScannedProcessor 内嵌图像素 候选在水印所在 XObject 内嵌图中的像素位置
ImageInpaintProcessor 原图像素 候选在原图上的像素位置

为什么扫描件处理器要用"内嵌图像素"而不是"页面点"?因为扫描件的水印是 烤进图片像素 的,检测和修复都必须在像素空间进行。但 UI 上要显示红框,又得换算回页面点。所以 PdfScannedProcessor 重写了基类的 candidate_page_bbox:

python 复制代码
def candidate_page_bbox(self, cand) -> tuple:
    """内嵌图像素候选 bbox → 页面点坐标(供 UI 在渲染图上定位)"""
    info = next((i for i in infos if i['xref'] == cand.xref), None)
    img_w, img_h = info['width'], info['height']
    px0, py0, px1, py1 = info['bbox']
    place_w = px1 - px0
    place_h = py1 - py0
    x0 = px0 + cand.bbox[0] / img_w * place_w
    y0 = py0 + cand.bbox[1] / img_h * place_h
    ...

这套换算在 UI 渲染时被频繁调用。坐标用错一个量级,红框就会跑到页面外。我们在第 7 篇 UI 篇会专门讲这个坑。

六、UI 层:三栏工作台

界面布局遵循"左导航 + 中工作区 + 右详情"的经典三栏:

复制代码
┌─────────────────────────────────────────────────────────┐
│ 工具栏:打开 检测 移除 工具切换 笔刷 高亮 对比 设置 手册  │
├──────────┬──────────────────────────────┬──────────────┤
│ 左栏     │ 中栏 PreviewWidget           │ 右栏         │
│ 文件/    │ ┌──────────────────────────┐ │ ResultPanel │
│ 任务列表 │ │ QPainter 渲染页面         │ │ 候选列表   │
│         │ │ 红框标记水印             │ │ 勾选/取消   │
│ 文件A   │ │ 鼠标框选/画笔涂抹         │ │ 置信度     │
│ 文件B   │ │ 前后对比双图并排          │ │ 来源标签   │
│ 文件C   │ └──────────────────────────┘ │            │
│         │                              │ 全选/全不选  │
│         │                              │ 移除并保存   │
├──────────┴──────────────────────────────┴──────────────┤
│ 状态栏:当前操作 页码 文件路径                          │
└─────────────────────────────────────────────────────────┘

操作按钮(全选/全不选/移除并保存)固定在右栏底部,永不随滚动条消失,这是 V2.0 改了一版才确定的交互------第一版把按钮放在列表头部,结果列表一长按钮就找不到了,被同事吐槽过。

预览区用 QPainter 手绘------不用 QLabel + QPixmap 的简单方案,因为我们要在 pixmap 上叠加红框、用户框选轨迹、画笔轨迹、对比双图,这些都需要 painter 灵活控制 z-order。具体实现见第 7 篇。

七、批量处理:状态机 + 运行守卫

批量是 V2.0 的重点。core/task_orchestrator.py 用 ThreadPoolExecutor 并发处理:

python 复制代码
def detect_all(self, progress_callback=None, cancel_check=None) -> None:
    self._cancelled = False
    pending = [t for t in self._tasks
               if t.status in (TaskStatus.PENDING, TaskStatus.FAILED)]
    total = len(pending)
    if total == 0:
        return
    done = 0
    with ThreadPoolExecutor(max_workers=self._max_workers) as executor:
        future_map = {executor.submit(self._detect_one, t): t for t in pending}
        for future in as_completed(future_map):
            if (cancel_check and cancel_check()) or self._cancelled:
                self._cancelled = True
                for f in future_map:
                    f.cancel()
                break
            ...

每个 TaskItem 都有自己的状态机:PENDING → DETECTING → DETECTED → REMOVING → COMPLETED,失败转到 FAILED,无水印转到 SKIPPED。UI 上的列表项按状态显示不同后缀,比如"3 候选/1 移除/失败:权限不足"。

UI 线程守卫是核心坑:批量按钮必须在执行期间禁用,否则用户连点会触发多个 worker 同时操作同一个文件,结果就是 core dump。关闭窗口时也要 wait() 后台线程结束才能退出,不然 Qt 会在析构时崩溃。这套坑我们在第 9 篇详细讲。

八、跨平台:一套代码跑麒麟 V10 + Windows 11

最后是工程化部分。我们的目标是同一份源码,在 Windows 11 和银河麒麟 V10 上都能直接跑,不需要任何条件编译。关键策略:

维度 实现
字体 main._pick_default_font() 启动时按平台选:Win=Microsoft YaHei UI;macOS=PingFang SC;Linux=Noto Sans CJK SC→WenQuanYi→系统默认
打开目录 Windows 调 explorer,macOS 调 open,Linux 调 xdg-open
Linux 任务栏分组 icons.ensure_linux_desktop_file 用 sys.executable 写 .desktop 文件
路径分隔符 全部用 os.path.join / os.pathsep,不硬编码
文件编码 所有 open() 显式 encoding='utf-8',避免 Windows 默认 GBK
PyInstaller 打包 build.py 用 os.pathsep 自动适配 --add-data 分隔符;Windows 附 .ico;Linux 附 .desktop

这一套适配在第 11 篇专门讲,踩过的坑够写一篇长文了。

九、本系列后续文章索引

为了让读者按需阅读,本系列共 12 篇,按以下顺序更新:

  1. 【开篇】 为什么我们要做一款本地文档去水印工具(本文)
  2. 【架构】 多格式文档处理系统分层架构与处理器路由
  3. 【PDF 矢量 · 上】 手写 PDF 内容流解析器:从字节流到结构化水印候选
  4. 【PDF 矢量 · 下】 矢量水印多策略检测与无损移除
  5. 【扫描件】 烤入扫描图的浅色斜铺文字水印:像素级 OCR 验证与掩膜修复
  6. 【图片】 通用图像去水印:颜色聚类分离 + Alpha 反演修复
  7. 【UI · 上】 PySide6 三栏工作台:QPainter 渲染、坐标契约与框选/涂抹交互
  8. 【UI · 下】 前后对比模式与多分辨率图标系统
  9. 【批量】 多文件并发任务编排:ThreadPoolExecutor + 运行守卫 + 状态机
  10. 【规则库】 扫描全能王/WPS/福昕水印规则库:JSON DSL + 关键词匹配 + 视觉特征加权
  11. 【跨平台】 一套代码适配银河麒麟 V10 与 Windows 11
  12. 【避坑实录】 那些深夜调试的坑:drawPixmap 崩溃、update_stream 黑块、多次移除白板、高 DPI 警告

十、下载与资源

清印 ClearMark V2.0 提供 源码 与 可执行程序 两种发行方式:

  • 源码版本 :包含全部 30 个 Python 文件、规则库、图标资源、打包脚本,可直接 python main.py 运行,适合二次开发与学习。
  • 绿色免安装版:基于 PyInstaller onedir 模式打包,整个文件夹拷到目标机器即可运行,已包含 Python 运行时与全部依赖,适合直接使用。

适用平台:

  • Windows 10 / 11 (x64)
  • 银河麒麟 V10 桌面版(x86_64 / aarch64)
  • macOS(实验性支持)

📥 下载地址:下载链接将在系列文章全部发布后统一更新。

如果你正在做以下事情,这套源码会对你有帮助:

  • 想了解 PDF 内容流(content stream)的结构与解析方式
  • 想学习 PyMuPDF 的高级 API(XObject、OCG、注释、replace_image)
  • 想实践 PySide6 + QPainter 的桌面应用开发
  • 想研究 OpenCV 图像修复(Telea/NS 算法、K-Means 聚类、形态学操作)
  • 想做跨平台(Linux + Windows)桌面工具的工程化
  • 想了解批量并发任务编排与 Qt 线程守卫的最佳实践

写在最后

做这个工具的过程里,最让我感慨的是:水印这件事看起来是个产品功能,本质上是一组 PDF 与图像算法的工程化整合。从内容流解析、几何变换、形态学、聚类、OCR、图像修复,到线程模型、坐标契约、跨平台------每一块单独都能写一本书。把它们整合成一个能用的桌面工具,靠的是工程取舍:什么时候用启发式、什么时候上 OCR、什么时候交给用户手动框选、什么时候放弃自动化只做半自动。

这个系列我想做的不是营销,而是把每一块的设计取舍讲清楚。如果你也在做类似的工具,或者只是好奇 PDF 内部到底长什么样,欢迎跟着读下去。下一篇文章我们会聊 整体架构与处理器路由 ------为什么是三层、为什么是策略模式、IProcessor 接口怎么设计才让 UI 层不感知格式差异。


作者注:本系列基于真实项目开发过程,所有代码、调试故事、坑点均为一手记录。文中代码片段均为项目实际实现,对应文件路径会在文中标注。如果你希望提前拿到完整源码,可以从文末下载链接获取。

相关推荐
imbackneverdie17 天前
告别 Copilot?Codex 本地化部署指南
人工智能·ai·aigc·数据可视化·本地化·codex
努力就够了22 天前
TraceKit——你自己的网站分析平台
本地化·埋点·自托管前端行为分析
用心_承载未来4 个月前
从“复制链接→打开APP“到“一键解析“:我做了个短视频去水印工具
python·去水印·短视频去水印
weixin_408099674 个月前
2026 豆包生图去水印完全指南:6种官方+第三方方案实测(附API对接)
图片处理·去水印·豆包·ai生图·石榴智能·豆包去水印·图片去水印api
yivifu4 个月前
跟水印杠上了——顺便巩固Tkinter的GUI编程
python·opencv·tkinter·去水印
yivifu5 个月前
使用PyMuPDF基于对PDF文档内容的分析自动识别并删除PDF文件中的水印
python·pdf·pymupdf·去水印
小贺儿开发5 个月前
Unity3D 本地 Stable Diffusion 文生图效果演示
人工智能·unity·stable diffusion·文生图·ai绘画·本地化
weixin_408099676 个月前
【组合实战】OCR + 图片去水印 API:自动清洗图片再识别文字(完整方案 + 代码示例)
图像处理·后端·ocr·api·文字识别·去水印·ocr识别优化
yivifu6 个月前
完美的PyMuPDF删除pdf页面文字水印
python·pdf·pymupdf·去水印