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

Written by

in

文章目录
  • 目标界面(和你提供的截图一致的交互形态): 左侧「水印参数配置」 水印文字(可编辑) 字体大小(像素) 透明度(0-100) 文字颜色(弹窗取色) 水印位置(左上/右上/左下/右下/居中) 全屏水印模式(忽略位置,按间距平铺) 水印旋转角度 水平/垂直间距 行数/列数(可选,辅助控制密度) 两个按钮:处理单个图片 / 批量处理文件夹 右侧「预览」 选择预览图片后,参数变化即时刷新预览 批量处理结束时弹出提示:成功处理图片数量
  • 这个项目以单文件实现为主,代码结构仍然保持「可扩展」: WatermarkSettings:水印配置数据模型(dataclass) MainWindow:界面与交互 _build_ui():搭界面 _wire_signals():绑定信号,实现“改参数即刷新预览” _apply_watermark():把水印绘制到图片上 _process_single_image():单图处理 _process_folder():文件夹批量处理
  • 思路很简单:把所有“会影响预览”的控件信号都绑定到 _update_preview()。 在代码里使用了一个小技巧:遍历控件列表,按控件可能拥有的信号类型去连接: 文本框:textChanged SpinBox:valueChanged ComboBox:currentTextChanged CheckBox:toggled 对应实现位置:MainWindow._wire_signals() 这样新增控件也方便:只要把控件加进列表,就能自动获得“联动预览”能力。
  • 水印绘制主要分两类: 单个水印(按位置绘制一次) 全屏水印(按间距平铺绘制多次) 它们都共享同一段“准备画笔”的逻辑: 转成 QImage.Format_ARGB32,确保支持透明度混合 painter.setOpacity(opacity/100) 设置整体透明度 设置字体像素大小 font.setPixelSize(font_size) QPen(QColor) 设置文字颜色 对应实现位置:MainWindow._apply_watermark()
  • 如果你想把它升级成“更像产品”的工具,推荐从这些方向迭代: 支持图片缩放后再加水印(例如输出统一宽度) 支持输出质量/压缩率(JPG quality) 支持水印阴影/描边(增强可读性) 支持导出到自定义目录,而不是固定 watermarked_output 批量处理加进度条与取消按钮(避免大文件夹卡住) 支持图片 EXIF 方向纠正(部分手机照片会旋转)
  • 如果你想发给没有 Python 环境的同学使用,最常见做法是用 PyInstaller 打包。
  • 程序入口:main() -> MainWindow() 预览刷新:_update_preview() 水印渲染:_apply_watermark() -> _draw_single() / _draw_tiled() 批量扫描:iter_image_files() 如果你准备把项目拆成“UI + 业务 + 工具函数”的结构,也可以在后续把水印渲染独立成一个模块(例如 watermark.py),界面只负责读参数和调用即可。
  • 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图片添加水印的资料请关注风君子博客其它相关文章! 您可能感兴趣的文章: Python编写一个图片批量添加文字水印工具(附代码) 基于Python实现图片批量加水印的方法 Python如何快速在PDF中添加文字或者图片水印 基于Python实现一个图片水印批量添加工具 Python脚本实现给图片添加安全斜向水印 基于Python编写图片去水印小工具 Python FastAPI+Celery+RabbitMQ实现分布式图片水印处理系统 Python+OpenCV图片去水印的多种方案实现
  • 目录
    • 1. 需求与界面目标
      • 1.1 效果截图
    • 2. 技术选型与依赖
      • 2.1 GUI:PyQt5
      • 2.2 图片处理:QImage + QPainter
    • 3. 工程结构(单文件也能清晰)
      • 4. 界面实现:左配右预览
        • 4.1 左侧参数配置区
        • 4.2 右侧预览区
      • 5. 关键交互:参数变化即刷新预览
        • 6. 水印渲染核心:QPainter 怎么画文字水印
          • 6.1 单个水印:位置 + 旋转
          • 6.2 全屏平铺水印:平铺范围与密度控制
        • 7. 处理单图与批量处理:文件选择与输出规则
          • 7.1 处理单图
          • 7.2 批量处理文件夹
        • 8. 如何运行
          • 8.1 安装依赖
          • 8.2 启动程序
        • 9. 常见问题(FAQ)
          • 9.1 为什么有些图片保存失败?
          • 9.2 透明度为什么感觉不明显?
          • 9.3 全屏水印密度怎么控制?
        • 10. 可扩展方向
          • 11. 打包成可执行程序(Windows)
            • 11.1 安装 PyInstaller
            • 11.2 一键打包(无控制台窗口)
            • 11.3 常见打包问题
          • 12. 关键代码入口
            • 13.完整代码

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

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

              • 左侧「水印参数配置」
                • 水印文字(可编辑)
                • 字体大小(像素)
                • 透明度(0-100)
                • 文字颜色(弹窗取色)
                • 水印位置(左上/右上/左下/右下/居中)
                • 全屏水印模式(忽略位置,按间距平铺)
                • 水印旋转角度
                • 水平/垂直间距
                • 行数/列数(可选,辅助控制密度)
                • 两个按钮:处理单个图片 / 批量处理文件夹
              • 右侧「预览」
                • 选择预览图片后,参数变化即时刷新预览
              • 批量处理结束时弹出提示:成功处理图片数量

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

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

              使用 PyQt5 的原因:

              • 组件完善(输入框、下拉框、取色对话框、文件选择对话框等一应俱全)
              • QPainter 绘制文字水印非常顺手
              • 跨平台(Windows/macOS/Linux)

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

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

              • WatermarkSettings:水印配置数据模型(dataclass)
              • MainWindow:界面与交互
                • _build_ui():搭界面
                • _wire_signals():绑定信号,实现“改参数即刷新预览”
                • _apply_watermark():把水印绘制到图片上
                • _process_single_image():单图处理
                • _process_folder():文件夹批量处理

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

              典型控件选择:

              • 文本:QLineEdit
              • 数值:QSpinBox(整数)、QDoubleSpinBox(旋转角度)
              • 下拉:QComboBox
              • 开关:QCheckBox
              • 取色:QColorDialog

              对应实现位置:

              • MainWindow._build_ui():创建控件并组装布局

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

              • 通过 QPixmap.fromImage()QImage 转为 QPixmap
              • 使用 scaled(..., Qt.KeepAspectRatio, Qt.SmoothTransformation) 自适应缩放

              预览逻辑在:

              • MainWindow._update_preview()
              • 并在窗口 resizeEvent 里触发刷新,保证拖动窗口大小时预览不变形

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

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

              • 文本框:textChanged
              • SpinBox:valueChanged
              • ComboBox:currentTextChanged
              • CheckBox:toggled

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

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

              水印绘制主要分两类:

              • 单个水印(按位置绘制一次)
              • 全屏水印(按间距平铺绘制多次)

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

              • 转成 QImage.Format_ARGB32,确保支持透明度混合
              • painter.setOpacity(opacity/100) 设置整体透明度
              • 设置字体像素大小 font.setPixelSize(font_size)
              • QPen(QColor) 设置文字颜色

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

              单个水印的关键点:

              • 先用 QFontMetricsF 测量文字矩形(用于右下等位置的对齐)
              • 通过 painter.translate(...) 把坐标系移动到文字中心
              • painter.rotate(angle) 旋转
              • 在“旋转后的坐标系”里绘制文字

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

              平铺水印的关键点:

              • 把坐标系移动到图片中心再旋转,这样平铺更自然
              • 使用 spacing_x / spacing_y 控制密度
              • 可选 rows / cols:当用户指定行列数时,用图片宽高反推间距
              • 平铺范围用 [-w, w][-h, h],保证旋转后边角仍覆盖

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

              流程:

              • 弹出文件选择框选图
              • 读取 QImage
              • 调用水印渲染
              • 保存为同目录 *_watermarked.ext
              • 弹窗提示 “已生成 1 张图片”

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

              流程:

              • 选择文件夹
              • 递归扫描图片文件扩展名:.jpg/.jpeg/.png/.bmp/.webp
              • 输出目录:<选择的文件夹>/watermarked_output/
              • 逐张保存,同名覆盖输出目录下同名文件
              • 弹窗提示成功处理数量

              对应实现位置:

              • iter_image_files()
              • MainWindow._process_folder()

              pip install PyQt5
              

              在项目目录执行:

              python main.py
              

              可能原因:

              • 图片路径包含特殊字符导致保存权限不足(建议输出到有写权限的目录)
              • 图片格式不支持写入(已支持常见格式;少见格式可先转为 PNG/JPG)

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

              • 提高字体大小
              • 调整颜色对比
              • 提高透明度(更接近 100)

              优先级:

              • 行数/列数 > 0,则用它们反推间距(更直观)
              • 否则用 水平间距/垂直间距 直接控制

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

              • 支持图片缩放后再加水印(例如输出统一宽度)
              • 支持输出质量/压缩率(JPG quality)
              • 支持水印阴影/描边(增强可读性)
              • 支持导出到自定义目录,而不是固定 watermarked_output
              • 批量处理加进度条与取消按钮(避免大文件夹卡住)
              • 支持图片 EXIF 方向纠正(部分手机照片会旋转)

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

              pip install pyinstaller
              

              在项目目录执行:

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

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

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

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

              • 程序入口:main() -> MainWindow()
              • 预览刷新:_update_preview()
              • 水印渲染:_apply_watermark() -> _draw_single() / _draw_tiled()
              • 批量扫描:iter_image_files()

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

              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图片添加水印的资料请关注风君子博客其它相关文章!

              您可能感兴趣的文章:

              • Python编写一个图片批量添加文字水印工具(附代码)
              • 基于Python实现图片批量加水印的方法
              • Python如何快速在PDF中添加文字或者图片水印
              • 基于Python实现一个图片水印批量添加工具
              • Python脚本实现给图片添加安全斜向水印
              • 基于Python编写图片去水印小工具
              • Python FastAPI+Celery+RabbitMQ实现分布式图片水印处理系统
              • Python+OpenCV图片去水印的多种方案实现

              站内搜索