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

1. 需求与界面目标
目标界面(和你提供的截图一致的交互形态):
- 左侧「水印参数配置」
- 水印文字(可编辑)
- 字体大小(像素)
- 透明度(0-100)
- 文字颜色(弹窗取色)
- 水印位置(左上/右上/左下/右下/居中)
- 全屏水印模式(忽略位置,按间距平铺)
- 水印旋转角度
- 水平/垂直间距
- 行数/列数(可选,辅助控制密度)
- 两个按钮:处理单个图片 / 批量处理文件夹
- 右侧「预览」
- 选择预览图片后,参数变化即时刷新预览
- 批量处理结束时弹出提示:成功处理图片数量
1.1 效果截图
你可以把截图放到项目目录下(例如 assets/ui.png),然后在博客里引用:

2. 技术选型与依赖
2.1 GUI:PyQt5
使用 PyQt5 的原因:
- 组件完善(输入框、下拉框、取色对话框、文件选择对话框等一应俱全)
- QPainter 绘制文字水印非常顺手
- 跨平台(Windows/macOS/Linux)
2.2 图片处理:QImage + QPainter
核心思路:把原图读入为 QImage,创建一个同尺寸画布,在画布上先绘制原图,再绘制水印文字,最后保存到目标路径。
3. 工程结构(单文件也能清晰)
这个项目以单文件实现为主,代码结构仍然保持「可扩展」:
WatermarkSettings:水印配置数据模型(dataclass)MainWindow:界面与交互_build_ui():搭界面_wire_signals():绑定信号,实现“改参数即刷新预览”_apply_watermark():把水印绘制到图片上_process_single_image():单图处理_process_folder():文件夹批量处理
4. 界面实现:左配右预览
4.1 左侧参数配置区
左侧用一个 QGroupBox("水印参数配置") 包起来,内部用 QVBoxLayout 纵向堆叠每一行配置项,每一行再用 QHBoxLayout 实现“标签 + 控件”的排列。
典型控件选择:
- 文本:
QLineEdit - 数值:
QSpinBox(整数)、QDoubleSpinBox(旋转角度) - 下拉:
QComboBox - 开关:
QCheckBox - 取色:
QColorDialog
对应实现位置:
MainWindow._build_ui():创建控件并组装布局
4.2 右侧预览区
右侧同样用 QGroupBox("预览") 包起来,核心是一个 QLabel 作为画布:
- 通过
QPixmap.fromImage()将QImage转为QPixmap - 使用
scaled(..., Qt.KeepAspectRatio, Qt.SmoothTransformation)自适应缩放
预览逻辑在:
MainWindow._update_preview()- 并在窗口
resizeEvent里触发刷新,保证拖动窗口大小时预览不变形
5. 关键交互:参数变化即刷新预览
思路很简单:把所有“会影响预览”的控件信号都绑定到 _update_preview()。
在代码里使用了一个小技巧:遍历控件列表,按控件可能拥有的信号类型去连接:
- 文本框:
textChanged - SpinBox:
valueChanged - ComboBox:
currentTextChanged - CheckBox:
toggled
对应实现位置:MainWindow._wire_signals()
这样新增控件也方便:只要把控件加进列表,就能自动获得“联动预览”能力。
6. 水印渲染核心:QPainter 怎么画文字水印
水印绘制主要分两类:
- 单个水印(按位置绘制一次)
- 全屏水印(按间距平铺绘制多次)
它们都共享同一段“准备画笔”的逻辑:
- 转成
QImage.Format_ARGB32,确保支持透明度混合 painter.setOpacity(opacity/100)设置整体透明度- 设置字体像素大小
font.setPixelSize(font_size) QPen(QColor)设置文字颜色
对应实现位置:MainWindow._apply_watermark()
6.1 单个水印:位置 + 旋转
单个水印的关键点:
- 先用
QFontMetricsF测量文字矩形(用于右下等位置的对齐) - 通过
painter.translate(...)把坐标系移动到文字中心 painter.rotate(angle)旋转- 在“旋转后的坐标系”里绘制文字
对应实现位置:MainWindow._draw_single()
6.2 全屏平铺水印:平铺范围与密度控制
平铺水印的关键点:
- 把坐标系移动到图片中心再旋转,这样平铺更自然
- 使用
spacing_x / spacing_y控制密度 - 可选
rows / cols:当用户指定行列数时,用图片宽高反推间距 - 平铺范围用
[-w, w]、[-h, h],保证旋转后边角仍覆盖
对应实现位置:MainWindow._draw_tiled()
7. 处理单图与批量处理:文件选择与输出规则
7.1 处理单图
流程:
- 弹出文件选择框选图
- 读取
QImage - 调用水印渲染
- 保存为同目录
*_watermarked.ext - 弹窗提示 “已生成 1 张图片”
对应实现位置:MainWindow._process_single_image()
7.2 批量处理文件夹
流程:
- 选择文件夹
- 递归扫描图片文件扩展名:
.jpg/.jpeg/.png/.bmp/.webp - 输出目录:
<选择的文件夹>/watermarked_output/ - 逐张保存,同名覆盖输出目录下同名文件
- 弹窗提示成功处理数量
对应实现位置:
iter_image_files()MainWindow._process_folder()
8. 如何运行
8.1 安装依赖
pip install PyQt5
8.2 启动程序
在项目目录执行:
python main.py
9. 常见问题(FAQ)
9.1 为什么有些图片保存失败?
可能原因:
- 图片路径包含特殊字符导致保存权限不足(建议输出到有写权限的目录)
- 图片格式不支持写入(已支持常见格式;少见格式可先转为 PNG/JPG)
9.2 透明度为什么感觉不明显?
透明度是对“整个绘制操作”生效的,与背景颜色、图片亮度有关。深色 图上浅色文字会更明显;如果不明显,可以:
- 提高字体大小
- 调整颜色对比
- 提高透明度(更接近 100)
9.3 全屏水印密度怎么控制?
优先级:
- 若
行数/列数> 0,则用它们反推间距(更直观) - 否则用
水平间距/垂直间距直接控制
10. 可扩展方向
如果你想把它升级成“更像产品”的工具,推荐从这些方向迭代:
- 支持图片缩放后再加水印(例如输出统一宽度)
- 支持输出质量/压缩率(JPG quality)
- 支持水印阴影/描边(增强可读性)
- 支持导出到自定义目录,而不是固定
watermarked_output - 批量处理加进度条与取消按钮(避免大文件夹卡住)
- 支持图片 EXIF 方向纠正(部分手机照片会旋转)
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. 关键代码入口
- 程序入口:
main()->MainWindow() - 预览刷新:
_update_preview() - 水印渲染:
_apply_watermark()->_draw_single()/_draw_tiled() - 批量扫描:
iter_image_files()
如果你准备把项目拆成“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图片添加水印的资料请关注脚本之家其它相关文章!
相关文章
基于MSELoss()与CrossEntropyLoss()的区别详解
今天小编就为大家分享一篇基于MSELoss()与CrossEntropyLoss()的区别详解,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧2020-01-01


最新评论