python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python图片添加水印

Python+PyQt5编写一个批量图片添加水印工具(附源码)

作者:小庄-Python办公

这篇文章主要为大家详细介绍了如何基于PyQt5开发的批量图片水印添加工具,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下

这篇文章完整记录一个「批量图片添加水印」桌面工具的研发过程:左侧配置水印参数,右侧实时预览效果,支持单张处理与文件夹批量处理,适合用作 PyQt5 入门到实战的小项目。

1. 需求与界面目标

目标界面(和你提供的截图一致的交互形态):

1.1 效果截图

你可以把截图放到项目目录下(例如 assets/ui.png),然后在博客里引用:

![批量图片加水印工具-界面预览](assets/ui.png)

2. 技术选型与依赖

2.1 GUI:PyQt5

使用 PyQt5 的原因:

2.2 图片处理:QImage + QPainter

核心思路:把原图读入为 QImage,创建一个同尺寸画布,在画布上先绘制原图,再绘制水印文字,最后保存到目标路径。

3. 工程结构(单文件也能清晰)

这个项目以单文件实现为主,代码结构仍然保持「可扩展」:

4. 界面实现:左配右预览

4.1 左侧参数配置区

左侧用一个 QGroupBox("水印参数配置") 包起来,内部用 QVBoxLayout 纵向堆叠每一行配置项,每一行再用 QHBoxLayout 实现“标签 + 控件”的排列。

典型控件选择:

对应实现位置:

4.2 右侧预览区

右侧同样用 QGroupBox("预览") 包起来,核心是一个 QLabel 作为画布:

预览逻辑在:

5. 关键交互:参数变化即刷新预览

思路很简单:把所有“会影响预览”的控件信号都绑定到 _update_preview()

在代码里使用了一个小技巧:遍历控件列表,按控件可能拥有的信号类型去连接:

对应实现位置:MainWindow._wire_signals()

这样新增控件也方便:只要把控件加进列表,就能自动获得“联动预览”能力。

6. 水印渲染核心:QPainter 怎么画文字水印

水印绘制主要分两类:

它们都共享同一段“准备画笔”的逻辑:

对应实现位置:MainWindow._apply_watermark()

6.1 单个水印:位置 + 旋转

单个水印的关键点:

对应实现位置:MainWindow._draw_single()

6.2 全屏平铺水印:平铺范围与密度控制

平铺水印的关键点:

对应实现位置:MainWindow._draw_tiled()

7. 处理单图与批量处理:文件选择与输出规则

7.1 处理单图

流程:

对应实现位置:MainWindow._process_single_image()

7.2 批量处理文件夹

流程:

对应实现位置:

8. 如何运行

8.1 安装依赖

pip install PyQt5

8.2 启动程序

在项目目录执行:

python main.py

9. 常见问题(FAQ)

9.1 为什么有些图片保存失败?

可能原因:

9.2 透明度为什么感觉不明显?

透明度是对“整个绘制操作”生效的,与背景颜色、图片亮度有关。深色 图上浅色文字会更明显;如果不明显,可以:

9.3 全屏水印密度怎么控制?

优先级:

10. 可扩展方向

如果你想把它升级成“更像产品”的工具,推荐从这些方向迭代:

11. 打包成可执行程序(Windows)

如果你想发给没有 Python 环境的同学使用,最常见做法是用 PyInstaller 打包。

11.1 安装 PyInstaller

pip install pyinstaller

11.2 一键打包(无控制台窗口)

在项目目录执行:

pyinstaller -F -w main.py --name 图片水印批量工具

打包完成后可执行文件通常在 dist/图片水印批量工具.exe

11.3 常见打包问题

如果运行时报缺少 Qt 插件(例如 platform plugin),可以尝试升级 PyInstaller,或使用:

pyinstaller -F -w main.py --name 图片水印批量工具 --collect-all PyQt5

12. 关键代码入口

如果你准备把项目拆成“UI + 业务 + 工具函数”的结构,也可以在后续把水印渲染独立成一个模块(例如 watermark.py),界面只负责读参数和调用即可。

13.完整代码

import os
import sys
from dataclasses import dataclass
from typing import Iterable, Optional

from PyQt5.QtCore import Qt, QPointF
from PyQt5.QtGui import QColor, QFont, QFontMetricsF, QImage, QPainter, QPen, QPixmap
from PyQt5.QtWidgets import (
    QApplication,
    QCheckBox,
    QColorDialog,
    QComboBox,
    QDoubleSpinBox,
    QFileDialog,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QSpinBox,
    QVBoxLayout,
    QWidget,
)


@dataclass(frozen=True)
class WatermarkSettings:
    text: str
    font_size: int
    opacity: int
    color: QColor
    position: str
    fullscreen: bool
    rotation: float
    spacing_x: int
    spacing_y: int
    rows: int
    cols: int


SUPPORTED_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}


def iter_image_files(folder: str) -> Iterable[str]:
    for root, _, files in os.walk(folder):
        for name in files:
            ext = os.path.splitext(name)[1].lower()
            if ext in SUPPORTED_EXTS:
                yield os.path.join(root, name)


class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("图片水印批量工具")
        self.resize(1100, 650)

        self._current_image_path: Optional[str] = None
        self._color = QColor(120, 0, 120)

        self._build_ui()
        self._wire_signals()
        self._update_color_button()
        self._update_preview()

    def _build_ui(self) -> None:
        root = QWidget(self)
        self.setCentralWidget(root)

        main_layout = QHBoxLayout(root)

        self.group_config = QGroupBox("水印参数配置", root)
        cfg_layout = QVBoxLayout(self.group_config)

        row1 = QHBoxLayout()
        row1.addWidget(QLabel("水印文字:", self.group_config))
        self.input_text = QLabel(self.group_config)
        self.input_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
        self.text_value = QLabel(self.group_config)
        self.text_value.hide()
        self.text_edit = None
        from PyQt5.QtWidgets import QLineEdit

        self.text_edit = QLineEdit(self.group_config)
        self.text_edit.setText("@小庄-Python办公")
        row1.addWidget(self.text_edit, 1)
        cfg_layout.addLayout(row1)

        row2 = QHBoxLayout()
        row2.addWidget(QLabel("字体大小:", self.group_config))
        self.spin_font = QSpinBox(self.group_config)
        self.spin_font.setRange(6, 400)
        self.spin_font.setValue(40)
        row2.addWidget(self.spin_font)
        row2.addSpacing(20)
        row2.addWidget(QLabel("透明度(0-100):", self.group_config))
        self.spin_opacity = QSpinBox(self.group_config)
        self.spin_opacity.setRange(0, 100)
        self.spin_opacity.setValue(80)
        row2.addWidget(self.spin_opacity)
        row2.addStretch(1)
        cfg_layout.addLayout(row2)

        row3 = QHBoxLayout()
        row3.addWidget(QLabel("文字颜色:", self.group_config))
        self.btn_color = QPushButton("选择颜色", self.group_config)
        self.btn_color.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        self.color_swatch = QLabel(self.group_config)
        self.color_swatch.setFixedSize(34, 22)
        self.color_swatch.setFrameShape(QLabel.Box)
        row3.addWidget(self.btn_color)
        row3.addWidget(self.color_swatch)
        row3.addStretch(1)
        cfg_layout.addLayout(row3)

        row4 = QHBoxLayout()
        row4.addWidget(QLabel("水印位置:", self.group_config))
        self.combo_pos = QComboBox(self.group_config)
        self.combo_pos.addItems(["左上", "右上", "左下", "右下", "居中"])
        self.combo_pos.setCurrentText("右下")
        row4.addWidget(self.combo_pos)
        row4.addStretch(1)
        cfg_layout.addLayout(row4)

        row5 = QHBoxLayout()
        self.chk_fullscreen = QCheckBox("全屏水印模式(忽略位置设置)", self.group_config)
        self.chk_fullscreen.setChecked(False)
        row5.addWidget(self.chk_fullscreen)
        row5.addStretch(1)
        cfg_layout.addLayout(row5)

        row6 = QHBoxLayout()
        row6.addWidget(QLabel("水印旋转角度:", self.group_config))
        self.spin_rotation = QDoubleSpinBox(self.group_config)
        self.spin_rotation.setRange(-180.0, 180.0)
        self.spin_rotation.setDecimals(1)
        self.spin_rotation.setSingleStep(1.0)
        self.spin_rotation.setValue(30.0)
        row6.addWidget(self.spin_rotation)
        row6.addStretch(1)
        cfg_layout.addLayout(row6)

        row7 = QHBoxLayout()
        row7.addWidget(QLabel("水平间距(像素):", self.group_config))
        self.spin_spacing_x = QSpinBox(self.group_config)
        self.spin_spacing_x.setRange(20, 5000)
        self.spin_spacing_x.setValue(360)
        row7.addWidget(self.spin_spacing_x)
        row7.addSpacing(20)
        row7.addWidget(QLabel("垂直间距(像素):", self.group_config))
        self.spin_spacing_y = QSpinBox(self.group_config)
        self.spin_spacing_y.setRange(20, 5000)
        self.spin_spacing_y.setValue(200)
        row7.addWidget(self.spin_spacing_y)
        row7.addStretch(1)
        cfg_layout.addLayout(row7)

        row8 = QHBoxLayout()
        row8.addWidget(QLabel("行数:", self.group_config))
        self.spin_rows = QSpinBox(self.group_config)
        self.spin_rows.setRange(0, 200)
        self.spin_rows.setValue(0)
        row8.addWidget(self.spin_rows)
        row8.addSpacing(20)
        row8.addWidget(QLabel("列数:", self.group_config))
        self.spin_cols = QSpinBox(self.group_config)
        self.spin_cols.setRange(0, 200)
        self.spin_cols.setValue(1)
        row8.addWidget(self.spin_cols)
        row8.addStretch(1)
        cfg_layout.addLayout(row8)

        cfg_layout.addStretch(1)

        btn_row = QHBoxLayout()
        self.btn_single = QPushButton("处理单个图片", self.group_config)
        self.btn_folder = QPushButton("批量处理文件夹", self.group_config)
        btn_row.addWidget(self.btn_single)
        btn_row.addWidget(self.btn_folder)
        cfg_layout.addLayout(btn_row)

        main_layout.addWidget(self.group_config, 0)

        self.group_preview = QGroupBox("预览", root)
        prev_layout = QVBoxLayout(self.group_preview)
        self.preview = QLabel(self.group_preview)
        self.preview.setAlignment(Qt.AlignCenter)
        self.preview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.preview.setMinimumSize(520, 520)
        self.preview.setStyleSheet("background: #1f1f1f; color: #dddddd;")
        prev_layout.addWidget(self.preview, 1)

        self.btn_choose_preview = QPushButton("选择预览图片", self.group_preview)
        prev_layout.addWidget(self.btn_choose_preview, 0, Qt.AlignRight)

        main_layout.addWidget(self.group_preview, 1)

    def _wire_signals(self) -> None:
        self.btn_color.clicked.connect(self._choose_color)
        self.btn_choose_preview.clicked.connect(self._choose_preview_image)
        self.btn_single.clicked.connect(self._process_single_image)
        self.btn_folder.clicked.connect(self._process_folder)

        for w in [
            self.text_edit,
            self.spin_font,
            self.spin_opacity,
            self.combo_pos,
            self.chk_fullscreen,
            self.spin_rotation,
            self.spin_spacing_x,
            self.spin_spacing_y,
            self.spin_rows,
            self.spin_cols,
        ]:
            if hasattr(w, "textChanged"):
                w.textChanged.connect(self._update_preview)
            if hasattr(w, "valueChanged"):
                w.valueChanged.connect(self._update_preview)
            if hasattr(w, "currentTextChanged"):
                w.currentTextChanged.connect(self._update_preview)
            if hasattr(w, "toggled"):
                w.toggled.connect(self._update_preview)

    def _settings(self) -> WatermarkSettings:
        return WatermarkSettings(
            text=self.text_edit.text().strip(),
            font_size=int(self.spin_font.value()),
            opacity=int(self.spin_opacity.value()),
            color=QColor(self._color),
            position=self.combo_pos.currentText(),
            fullscreen=self.chk_fullscreen.isChecked(),
            rotation=float(self.spin_rotation.value()),
            spacing_x=int(self.spin_spacing_x.value()),
            spacing_y=int(self.spin_spacing_y.value()),
            rows=int(self.spin_rows.value()),
            cols=int(self.spin_cols.value()),
        )

    def _choose_color(self) -> None:
        color = QColorDialog.getColor(self._color, self, "选择水印颜色")
        if color.isValid():
            self._color = color
            self._update_color_button()
            self._update_preview()

    def _update_color_button(self) -> None:
        self.color_swatch.setStyleSheet(
            f"background-color: {self._color.name()}; border: 1px solid #444;"
        )

    def _choose_preview_image(self) -> None:
        path, _ = QFileDialog.getOpenFileName(
            self,
            "选择图片",
            "",
            "Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)",
        )
        if not path:
            return
        self._current_image_path = path
        self._update_preview()

    def _process_single_image(self) -> None:
        src, _ = QFileDialog.getOpenFileName(
            self,
            "选择要处理的图片",
            "",
            "Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)",
        )
        if not src:
            return

        img = QImage(src)
        if img.isNull():
            QMessageBox.warning(self, "错误", "图片读取失败")
            return

        dst = self._suggest_output_path(src)
        ok = self._save_watermarked(img, dst)
        if not ok:
            QMessageBox.warning(self, "错误", "图片保存失败")
            return

        self._current_image_path = src
        self._update_preview()
        QMessageBox.information(self, "批量完成", "处理成功,已生成 1 张图片")

    def _process_folder(self) -> None:
        folder = QFileDialog.getExistingDirectory(self, "选择要批量处理的文件夹", "")
        if not folder:
            return

        out_dir = os.path.join(folder, "watermarked_output")
        os.makedirs(out_dir, exist_ok=True)

        count = 0
        first_image = None
        for src in iter_image_files(folder):
            if os.path.commonpath([src, out_dir]) == out_dir:
                continue
            if first_image is None:
                first_image = src

            img = QImage(src)
            if img.isNull():
                continue

            base = os.path.basename(src)
            dst = os.path.join(out_dir, base)
            if self._save_watermarked(img, dst):
                count += 1

        if first_image:
            self._current_image_path = first_image
            self._update_preview()

        QMessageBox.information(self, "批量完成", f"批量处理结束!\n共成功处理 {count} 张图片")

    def _suggest_output_path(self, src: str) -> str:
        root, ext = os.path.splitext(src)
        return f"{root}_watermarked{ext}"

    def _save_watermarked(self, src_img: QImage, dst: str) -> bool:
        settings = self._settings()
        if not settings.text:
            return False
        out = self._apply_watermark(src_img, settings)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        return out.save(dst)

    def _apply_watermark(self, src_img: QImage, settings: WatermarkSettings) -> QImage:
        if src_img.format() != QImage.Format_ARGB32:
            base = src_img.convertToFormat(QImage.Format_ARGB32)
        else:
            base = QImage(src_img)

        out = QImage(base.size(), QImage.Format_ARGB32)
        out.fill(Qt.transparent)

        painter = QPainter(out)
        painter.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing)
        painter.drawImage(0, 0, base)

        font = QFont()
        font.setPixelSize(settings.font_size)
        painter.setFont(font)

        pen = QPen(settings.color)
        painter.setPen(pen)
        painter.setOpacity(max(0.0, min(1.0, settings.opacity / 100.0)))

        if settings.fullscreen:
            self._draw_tiled(painter, out.width(), out.height(), settings)
        else:
            self._draw_single(painter, out.width(), out.height(), settings)

        painter.end()
        return out

    def _draw_single(self, painter: QPainter, w: int, h: int, settings: WatermarkSettings) -> None:
        metrics = QFontMetricsF(painter.font())
        rect = metrics.boundingRect(settings.text)
        margin = 20.0

        if settings.position == "左上":
            x = margin
            y = margin + rect.height()
        elif settings.position == "右上":
            x = w - margin - rect.width()
            y = margin + rect.height()
        elif settings.position == "左下":
            x = margin
            y = h - margin
        elif settings.position == "右下":
            x = w - margin - rect.width()
            y = h - margin
        else:
            x = (w - rect.width()) / 2.0
            y = (h + rect.height()) / 2.0

        painter.save()
        painter.translate(x + rect.width() / 2.0, y - rect.height() / 2.0)
        painter.rotate(settings.rotation)
        painter.drawText(QPointF(-rect.width() / 2.0, rect.height() / 2.0), settings.text)
        painter.restore()

    def _draw_tiled(self, painter: QPainter, w: int, h: int, settings: WatermarkSettings) -> None:
        metrics = QFontMetricsF(painter.font())
        rect = metrics.boundingRect(settings.text)

        spacing_x = max(20, settings.spacing_x)
        spacing_y = max(20, settings.spacing_y)
        if settings.cols > 0:
            spacing_x = max(20, int(w / max(1, settings.cols)))
        if settings.rows > 0:
            spacing_y = max(20, int(h / max(1, settings.rows)))

        painter.save()
        painter.translate(w / 2.0, h / 2.0)
        painter.rotate(settings.rotation)

        start_x = -w
        end_x = w
        start_y = -h
        end_y = h

        x = start_x
        while x <= end_x:
            y = start_y
            while y <= end_y:
                painter.drawText(QPointF(x, y), settings.text)
                y += spacing_y
            x += spacing_x

        painter.restore()

    def _update_preview(self) -> None:
        if not self._current_image_path:
            self.preview.setText("请选择预览图片")
            return

        img = QImage(self._current_image_path)
        if img.isNull():
            self.preview.setText("预览图片读取失败")
            return

        settings = self._settings()
        if not settings.text:
            rendered = img
        else:
            rendered = self._apply_watermark(img, settings)

        pix = QPixmap.fromImage(rendered)
        target = self.preview.size()
        if target.width() <= 1 or target.height() <= 1:
            self.preview.setPixmap(pix)
            return
        self.preview.setPixmap(pix.scaled(target, Qt.KeepAspectRatio, Qt.SmoothTransformation))

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self._update_preview()


def main() -> int:
    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    return app.exec_()


if __name__ == "__main__":
    raise SystemExit(main())


以上就是Python+PyQt5编写一个批量图片添加水印工具(附源码)的详细内容,更多关于Python图片添加水印的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文