Python+PyQt5編寫一個批量圖片添加水印工具(附源碼)
這篇文章完整記錄一個「批量圖片添加水印」桌面工具的研發(fā)過程:左側配置水印參數(shù),右側實時預覽效果,支持單張?zhí)幚砼c文件夾批量處理,適合用作 PyQt5 入門到實戰(zhàn)的小項目。

1. 需求與界面目標
目標界面(和你提供的截圖一致的交互形態(tài)):
- 左側「水印參數(shù)配置」
- 水印文字(可編輯)
- 字體大?。ㄏ袼兀?/li>
- 透明度(0-100)
- 文字顏色(彈窗取色)
- 水印位置(左上/右上/左下/右下/居中)
- 全屏水印模式(忽略位置,按間距平鋪)
- 水印旋轉角度
- 水平/垂直間距
- 行數(shù)/列數(shù)(可選,輔助控制密度)
- 兩個按鈕:處理單個圖片 / 批量處理文件夾
- 右側「預覽」
- 選擇預覽圖片后,參數(shù)變化即時刷新預覽
- 批量處理結束時彈出提示:成功處理圖片數(shù)量
1.1 效果截圖
你可以把截圖放到項目目錄下(例如 assets/ui.png),然后在博客里引用:

2. 技術選型與依賴
2.1 GUI:PyQt5
使用 PyQt5 的原因:
- 組件完善(輸入框、下拉框、取色對話框、文件選擇對話框等一應俱全)
- QPainter 繪制文字水印非常順手
- 跨平臺(Windows/macOS/Linux)
2.2 圖片處理:QImage + QPainter
核心思路:把原圖讀入為 QImage,創(chuàng)建一個同尺寸畫布,在畫布上先繪制原圖,再繪制水印文字,最后保存到目標路徑。
3. 工程結構(單文件也能清晰)
這個項目以單文件實現(xiàn)為主,代碼結構仍然保持「可擴展」:
WatermarkSettings:水印配置數(shù)據(jù)模型(dataclass)MainWindow:界面與交互_build_ui():搭界面_wire_signals():綁定信號,實現(xiàn)“改參數(shù)即刷新預覽”_apply_watermark():把水印繪制到圖片上_process_single_image():單圖處理_process_folder():文件夾批量處理
4. 界面實現(xiàn):左配右預覽
4.1 左側參數(shù)配置區(qū)
左側用一個 QGroupBox("水印參數(shù)配置") 包起來,內部用 QVBoxLayout 縱向堆疊每一行配置項,每一行再用 QHBoxLayout 實現(xiàn)“標簽 + 控件”的排列。
典型控件選擇:
- 文本:
QLineEdit - 數(shù)值:
QSpinBox(整數(shù))、QDoubleSpinBox(旋轉角度) - 下拉:
QComboBox - 開關:
QCheckBox - 取色:
QColorDialog
對應實現(xiàn)位置:
MainWindow._build_ui():創(chuàng)建控件并組裝布局
4.2 右側預覽區(qū)
右側同樣用 QGroupBox("預覽") 包起來,核心是一個 QLabel 作為畫布:
- 通過
QPixmap.fromImage()將QImage轉為QPixmap - 使用
scaled(..., Qt.KeepAspectRatio, Qt.SmoothTransformation)自適應縮放
預覽邏輯在:
MainWindow._update_preview()- 并在窗口
resizeEvent里觸發(fā)刷新,保證拖動窗口大小時預覽不變形
5. 關鍵交互:參數(shù)變化即刷新預覽
思路很簡單:把所有“會影響預覽”的控件信號都綁定到 _update_preview()。
在代碼里使用了一個小技巧:遍歷控件列表,按控件可能擁有的信號類型去連接:
- 文本框:
textChanged - SpinBox:
valueChanged - ComboBox:
currentTextChanged - CheckBox:
toggled
對應實現(xiàn)位置:MainWindow._wire_signals()
這樣新增控件也方便:只要把控件加進列表,就能自動獲得“聯(lián)動預覽”能力。
6. 水印渲染核心:QPainter 怎么畫文字水印
水印繪制主要分兩類:
- 單個水?。ò次恢美L制一次)
- 全屏水印(按間距平鋪繪制多次)
它們都共享同一段“準備畫筆”的邏輯:
- 轉成
QImage.Format_ARGB32,確保支持透明度混合 painter.setOpacity(opacity/100)設置整體透明度- 設置字體像素大小
font.setPixelSize(font_size) QPen(QColor)設置文字顏色
對應實現(xiàn)位置:MainWindow._apply_watermark()
6.1 單個水印:位置 + 旋轉
單個水印的關鍵點:
- 先用
QFontMetricsF測量文字矩形(用于右下等位置的對齊) - 通過
painter.translate(...)把坐標系移動到文字中心 painter.rotate(angle)旋轉- 在“旋轉后的坐標系”里繪制文字
對應實現(xiàn)位置:MainWindow._draw_single()
6.2 全屏平鋪水?。浩戒伔秶c密度控制
平鋪水印的關鍵點:
- 把坐標系移動到圖片中心再旋轉,這樣平鋪更自然
- 使用
spacing_x / spacing_y控制密度 - 可選
rows / cols:當用戶指定行列數(shù)時,用圖片寬高反推間距 - 平鋪范圍用
[-w, w]、[-h, h],保證旋轉后邊角仍覆蓋
對應實現(xiàn)位置:MainWindow._draw_tiled()
7. 處理單圖與批量處理:文件選擇與輸出規(guī)則
7.1 處理單圖
流程:
- 彈出文件選擇框選圖
- 讀取
QImage - 調用水印渲染
- 保存為同目錄
*_watermarked.ext - 彈窗提示 “已生成 1 張圖片”
對應實現(xiàn)位置:MainWindow._process_single_image()
7.2 批量處理文件夾
流程:
- 選擇文件夾
- 遞歸掃描圖片文件擴展名:
.jpg/.jpeg/.png/.bmp/.webp - 輸出目錄:
<選擇的文件夾>/watermarked_output/ - 逐張保存,同名覆蓋輸出目錄下同名文件
- 彈窗提示成功處理數(shù)量
對應實現(xiàn)位置:
iter_image_files()MainWindow._process_folder()
8. 如何運行
8.1 安裝依賴
pip install PyQt5
8.2 啟動程序
在項目目錄執(zhí)行:
python main.py
9. 常見問題(FAQ)
9.1 為什么有些圖片保存失?。?/h3>
可能原因:
- 圖片路徑包含特殊字符導致保存權限不足(建議輸出到有寫權限的目錄)
- 圖片格式不支持寫入(已支持常見格式;少見格式可先轉為 PNG/JPG)
9.2 透明度為什么感覺不明顯?
透明度是對“整個繪制操作”生效的,與背景顏色、圖片亮度有關。深色 圖上淺色文字會更明顯;如果不明顯,可以:
- 提高字體大小
- 調整顏色對比
- 提高透明度(更接近 100)
9.3 全屏水印密度怎么控制?
優(yōu)先級:
- 若
行數(shù)/列數(shù)> 0,則用它們反推間距(更直觀) - 否則用
水平間距/垂直間距直接控制
10. 可擴展方向
如果你想把它升級成“更像產品”的工具,推薦從這些方向迭代:
- 支持圖片縮放后再加水印(例如輸出統(tǒng)一寬度)
- 支持輸出質量/壓縮率(JPG quality)
- 支持水印陰影/描邊(增強可讀性)
- 支持導出到自定義目錄,而不是固定
watermarked_output - 批量處理加進度條與取消按鈕(避免大文件夾卡?。?/li>
- 支持圖片 EXIF 方向糾正(部分手機照片會旋轉)
11. 打包成可執(zhí)行程序(Windows)
如果你想發(fā)給沒有 Python 環(huán)境的同學使用,最常見做法是用 PyInstaller 打包。
11.1 安裝 PyInstaller
pip install pyinstaller
11.2 一鍵打包(無控制臺窗口)
在項目目錄執(zhí)行:
pyinstaller -F -w main.py --name 圖片水印批量工具
打包完成后可執(zhí)行文件通常在 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 + 業(yè)務 + 工具函數(shù)”的結構,也可以在后續(xù)把水印渲染獨立成一個模塊(例如 watermark.py),界面只負責讀參數(shù)和調用即可。
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("水印參數(shù)配置", 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("行數(shù):", 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("列數(shù):", 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如何在ubuntu中更改Python和pip指向
這篇文章主要介紹了Python如何在ubuntu中更改Python和pip指向問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-08-08
Python實現(xiàn)爬取騰訊招聘網(wǎng)崗位信息
這篇文章主要介紹了如何用python爬取騰訊招聘網(wǎng)崗位信息保存到表格,并做成簡單可視化。文中的示例代碼對學習Python有一定的幫助,感興趣的可以了解一下2022-01-01

