Python編寫一個圖片批量添加文字水印工具(附代碼)
在日常工作和自媒體運營中,我們經(jīng)常需要給大量的圖片添加水印以保護版權。市面上的工具要么收費,要么功能單一。今天,我們將使用 Python 強大的 GUI 庫 PyQt5 和圖像處理庫 Pillow (PIL),親手打造一個免費、開源且功能強大的批量水印工具。
項目目標
我們需要實現(xiàn)一個具備以下功能的桌面軟件:
- 批量處理:支持拖拽或選擇多個文件/文件夾。
- 可視化預覽:在調(diào)整參數(shù)時實時預覽水印效果。
- 高度自定義:支持設置水印文字、大小、顏色、透明度、旋轉角度。
- 布局靈活:支持九宮格位置(如左上、右下)以及全圖平鋪模式。
- 防卡頓:使用多線程處理圖片,避免界面凍結。
技術棧
Python 3.x
PyQt5: 用于構建圖形用戶界面 (GUI)。
Pillow (PIL): 用于核心的圖像處理(繪制文字、旋轉、合成)。
運行效果

環(huán)境搭建
首先,我們需要安裝必要的第三方庫:
pip install PyQt5 Pillow
核心實現(xiàn)思路
界面設計 (PyQt5)
我們將界面分為左右兩部分:
- 左側 (控制面板):包含文件列表、輸出路徑設置、以及所有的水印參數(shù)控件(輸入框、滑塊、下拉框等)。
- 右側 (預覽區(qū)):顯示當前選中圖片的實時預覽效果。
我們使用 QHBoxLayout (水平布局) 來容納左右面板,左側面板內(nèi)部使用 QVBoxLayout (垂直布局) 來排列各個設置組 (QGroupBox)。
圖像處理核心 (Pillow)
這是整個工具的靈魂。主要步驟如下:
1.打開圖片:使用 Image.open() 并轉換為 RGBA 模式以便處理透明度。
2.創(chuàng)建水印層:創(chuàng)建一個與原圖等大的透明圖層。
3.繪制文字:
- 使用
ImageDraw.Draw繪制文本。 - 計算文本大小 (
draw.textbbox) 以便居中或定位。 - 處理顏色和透明度。
4.旋轉與平鋪:
- 如果需要旋轉,先在一個單獨的小圖層上繪制文字并旋轉,然后粘貼到大水印層上。
- 平鋪模式:通過雙重循環(huán) (
for x... for y...) 計算坐標,將水印重復粘貼到全圖。
5.合成與保存:使用 Image.alpha_composite 將水印層疊加到原圖,最后保存。
多線程處理 (QThread)
為了防止在處理幾百張大圖時界面卡死(“未響應”),我們將耗時的圖片處理邏輯放入后臺線程 Worker 中。
class Worker(QThread):
progress = pyqtSignal(int) # 進度信號
finished = pyqtSignal(str) # 完成信號
def run(self):
# 遍歷文件列表進行處理
for i, file_path in enumerate(self.files):
self.process_image(file_path)
self.progress.emit(...) # 更新進度條
核心代碼解析
水印繪制邏輯
這是實現(xiàn)平鋪和定位的關鍵代碼片段:
def process_image(self, file_path):
with Image.open(file_path).convert("RGBA") as img:
# 創(chuàng)建全透明水印層
watermark = Image.new('RGBA', img.size, (0, 0, 0, 0))
# ... (省略字體加載和顏色設置) ...
# 創(chuàng)建單個水印小圖用于旋轉
txt_img = Image.new('RGBA', (max_dim, max_dim), (0, 0, 0, 0))
txt_draw = ImageDraw.Draw(txt_img)
txt_draw.text((text_x, text_y), text, font=font, fill=fill_color)
# 旋轉
if rotation != 0:
txt_img = txt_img.rotate(rotation, resample=Image.BICUBIC)
# 核心布局邏輯
if position == '平鋪 (Tile)':
# 雙重循環(huán)實現(xiàn)全圖平鋪
step_x = int(w_width + spacing)
step_y = int(w_height + spacing)
for y in range(0, img.height, step_y):
for x in range(0, img.width, step_x):
watermark.paste(txt_img, (x, y), txt_img)
else:
# 九宮格定位邏輯
# 根據(jù) '左', '右', '上', '下' 關鍵字計算坐標
# ...
watermark.paste(txt_img, (pos_x, pos_y), txt_img)
# 合成最終圖片
out = Image.alpha_composite(img, watermark)
實時預覽實現(xiàn)
預覽功能的難點在于性能。我們不能每次調(diào)整參數(shù)都去處理原圖(原圖可能幾千萬像素)。
優(yōu)化方案:
- 加載原圖后,先生成一個較小的縮略圖(例如最大邊長 800px)。
- 所有的預覽計算都在這個縮略圖上進行。
- 注意:字體大小和間距需要根據(jù)縮略圖的比例進行縮放,否則預覽效果會和實際輸出不一致。
# 縮放比例計算 scale_factor = preview_img.width / original_img_width # 字體大小也要隨之縮放 preview_font_size = int(user_set_font_size * scale_factor)
完整功能展示
運行 main.py 后,你將看到如下界面:
添加圖片:點擊“添加圖片”或“添加文件夾”導入素材。
調(diào)整參數(shù):
- 輸入文字 “My Watermark”。
- 拖動“旋轉角度”滑塊到 30 度。
- 選擇位置為“平鋪”。
- 調(diào)整透明度為 30% 使得水印不喧賓奪主。
預覽:右側會立即顯示效果,所見即所得。
輸出:選擇輸出目錄,點擊“開始處理”,進度條跑完即大功告成!
完整代碼
import sys
import os
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QSlider, QSpinBox,
QComboBox, QColorDialog, QProgressBar, QMessageBox, QGroupBox,
QScrollArea, QListWidget)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QPixmap, QImage, QColor, QFont
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
import math
class Worker(QThread):
progress = pyqtSignal(int)
finished = pyqtSignal(str)
error = pyqtSignal(str)
def __init__(self, files, output_dir, config):
super().__init__()
self.files = files
self.output_dir = output_dir
self.config = config
self.is_running = True
def run(self):
total = len(self.files)
success_count = 0
if not os.path.exists(self.output_dir):
try:
os.makedirs(self.output_dir)
except Exception as e:
self.error.emit(f"無法創(chuàng)建輸出目錄: {str(e)}")
return
for i, file_path in enumerate(self.files):
if not self.is_running:
break
try:
self.process_image(file_path)
success_count += 1
except Exception as e:
print(f"Error processing {file_path}: {e}")
self.progress.emit(int((i + 1) / total * 100))
self.finished.emit(f"處理完成!成功: {success_count}/{total}")
def process_image(self, file_path):
try:
with Image.open(file_path).convert("RGBA") as img:
# 創(chuàng)建水印層
watermark = Image.new('RGBA', img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(watermark)
text = self.config['text']
font_size = self.config['font_size']
opacity = self.config['opacity']
rotation = self.config['rotation']
color = self.config['color'] # Tuple (r, g, b)
position = self.config['position']
spacing = self.config['spacing'] # For tiling
# 加載字體 (使用默認字體,因為系統(tǒng)字體路徑復雜,這里簡化處理)
try:
# 嘗試使用微軟雅黑
font = ImageFont.truetype("msyh.ttc", font_size)
except:
font = ImageFont.load_default()
# default font doesn't scale well, but fallback is needed
# If we really want size, we might need a standard font file distributed with app
# Trying basic arial if msyh fails
try:
font = ImageFont.truetype("arial.ttf", font_size)
except:
pass # Fallback to default
# 計算文本大小
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 創(chuàng)建單個水印圖片用于旋轉
# 留出足夠空間以防旋轉后被裁剪
max_dim = int(math.sqrt(text_width**2 + text_height**2))
txt_img = Image.new('RGBA', (max_dim, max_dim), (0, 0, 0, 0))
txt_draw = ImageDraw.Draw(txt_img)
# 居中繪制文本
text_x = (max_dim - text_width) // 2
text_y = (max_dim - text_height) // 2
# 設置顏色和透明度
fill_color = (color[0], color[1], color[2], int(255 * opacity))
txt_draw.text((text_x, text_y), text, font=font, fill=fill_color)
# 旋轉
if rotation != 0:
txt_img = txt_img.rotate(rotation, resample=Image.BICUBIC)
# 獲取旋轉后的實際內(nèi)容邊界(可選,但為了精確布局最好做)
# 這里簡單處理,直接使用txt_img
w_width, w_height = txt_img.size
if position == '平鋪 (Tile)':
# 平鋪邏輯
# spacing 是間距倍數(shù)或像素
step_x = int(w_width + spacing)
step_y = int(w_height + spacing)
if step_x <= 0: step_x = w_width + 50
if step_y <= 0: step_y = w_height + 50
for y in range(0, img.height, step_y):
for x in range(0, img.width, step_x):
watermark.paste(txt_img, (x, y), txt_img)
else:
# 單個位置邏輯
pos_x = 0
pos_y = 0
margin = 20
if '左' in position:
pos_x = margin
elif '右' in position:
pos_x = img.width - w_width - margin
else: # 中 (水平)
pos_x = (img.width - w_width) // 2
if '上' in position:
pos_y = margin
elif '下' in position:
pos_y = img.height - w_height - margin
else: # 中 (垂直)
pos_y = (img.height - w_height) // 2
watermark.paste(txt_img, (pos_x, pos_y), txt_img)
# 合成
out = Image.alpha_composite(img, watermark)
# 保存
filename = os.path.basename(file_path)
save_path = os.path.join(self.output_dir, filename)
# Convert back to RGB if saving as JPEG, otherwise keep RGBA for PNG
if filename.lower().endswith(('.jpg', '.jpeg')):
out = out.convert('RGB')
out.save(save_path, quality=95)
else:
out.save(save_path)
except Exception as e:
print(f"Processing failed for {file_path}: {e}")
raise e
class WatermarkApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("批量圖片水印工具")
self.resize(1000, 700)
# Data
self.image_files = []
self.current_preview_image = None
self.watermark_color = (255, 255, 255) # Default white
# UI Setup
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QHBoxLayout(central_widget)
# Left Panel (Settings)
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_panel.setFixedWidth(350)
main_layout.addWidget(left_panel)
# 1. File Selection
grp_files = QGroupBox("文件選擇")
grp_files_layout = QVBoxLayout()
btn_layout = QHBoxLayout()
self.btn_add_files = QPushButton("添加圖片")
self.btn_add_files.clicked.connect(self.add_files)
self.btn_add_folder = QPushButton("添加文件夾")
self.btn_add_folder.clicked.connect(self.add_folder)
self.btn_clear_files = QPushButton("清空列表")
self.btn_clear_files.clicked.connect(self.clear_files)
btn_layout.addWidget(self.btn_add_files)
btn_layout.addWidget(self.btn_add_folder)
btn_layout.addWidget(self.btn_clear_files)
self.list_files = QListWidget()
self.list_files.currentRowChanged.connect(self.update_preview)
grp_files_layout.addLayout(btn_layout)
grp_files_layout.addWidget(self.list_files)
grp_files.setLayout(grp_files_layout)
left_layout.addWidget(grp_files)
# 2. Output Directory
grp_output = QGroupBox("輸出設置")
grp_output_layout = QVBoxLayout()
out_path_layout = QHBoxLayout()
self.edit_output = QLineEdit()
self.edit_output.setPlaceholderText("選擇輸出目錄...")
self.btn_browse_output = QPushButton("瀏覽")
self.btn_browse_output.clicked.connect(self.browse_output)
out_path_layout.addWidget(self.edit_output)
out_path_layout.addWidget(self.btn_browse_output)
grp_output_layout.addLayout(out_path_layout)
grp_output.setLayout(grp_output_layout)
left_layout.addWidget(grp_output)
# 3. Watermark Settings
grp_settings = QGroupBox("水印設置")
grp_settings_layout = QVBoxLayout()
# Text
self.edit_text = QLineEdit("Sample Watermark")
self.edit_text.setPlaceholderText("輸入水印文字")
self.edit_text.textChanged.connect(self.update_preview_delayed)
grp_settings_layout.addWidget(QLabel("水印文字:"))
grp_settings_layout.addWidget(self.edit_text)
# Color
color_layout = QHBoxLayout()
self.btn_color = QPushButton("選擇顏色")
self.btn_color.clicked.connect(self.choose_color)
self.lbl_color_preview = QLabel(" ")
self.lbl_color_preview.setStyleSheet("background-color: white; border: 1px solid black;")
self.lbl_color_preview.setFixedWidth(30)
color_layout.addWidget(QLabel("顏色:"))
color_layout.addWidget(self.btn_color)
color_layout.addWidget(self.lbl_color_preview)
color_layout.addStretch()
grp_settings_layout.addLayout(color_layout)
# Font Size
size_layout = QHBoxLayout()
self.spin_size = QSpinBox()
self.spin_size.setRange(10, 500)
self.spin_size.setValue(40)
self.spin_size.valueChanged.connect(self.update_preview_delayed)
size_layout.addWidget(QLabel("字體大小:"))
size_layout.addWidget(self.spin_size)
grp_settings_layout.addLayout(size_layout)
# Opacity
opacity_layout = QHBoxLayout()
self.slider_opacity = QSlider(Qt.Horizontal)
self.slider_opacity.setRange(0, 100)
self.slider_opacity.setValue(50)
self.slider_opacity.valueChanged.connect(self.update_preview_delayed)
opacity_layout.addWidget(QLabel("透明度:"))
opacity_layout.addWidget(self.slider_opacity)
grp_settings_layout.addLayout(opacity_layout)
# Rotation
rotation_layout = QHBoxLayout()
self.slider_rotation = QSlider(Qt.Horizontal)
self.slider_rotation.setRange(0, 360)
self.slider_rotation.setValue(0)
self.slider_rotation.valueChanged.connect(self.update_preview_delayed)
rotation_layout.addWidget(QLabel("旋轉角度:"))
rotation_layout.addWidget(self.slider_rotation)
grp_settings_layout.addLayout(rotation_layout)
# Position
pos_layout = QHBoxLayout()
self.combo_pos = QComboBox()
positions = [
"左上", "中上", "右上",
"左中", "正中", "右中",
"左下", "中下", "右下",
"平鋪 (Tile)"
]
self.combo_pos.addItems(positions)
self.combo_pos.setCurrentText("右下")
self.combo_pos.currentIndexChanged.connect(self.update_preview_delayed)
pos_layout.addWidget(QLabel("位置:"))
pos_layout.addWidget(self.combo_pos)
grp_settings_layout.addLayout(pos_layout)
# Spacing (only for tile)
spacing_layout = QHBoxLayout()
self.spin_spacing = QSpinBox()
self.spin_spacing.setRange(0, 500)
self.spin_spacing.setValue(100)
self.spin_spacing.valueChanged.connect(self.update_preview_delayed)
spacing_layout.addWidget(QLabel("間距 (平鋪):"))
spacing_layout.addWidget(self.spin_spacing)
grp_settings_layout.addLayout(spacing_layout)
grp_settings.setLayout(grp_settings_layout)
left_layout.addWidget(grp_settings)
left_layout.addStretch()
# Action Buttons
self.btn_start = QPushButton("開始處理")
self.btn_start.setMinimumHeight(40)
self.btn_start.setStyleSheet("font-weight: bold; font-size: 14px;")
self.btn_start.clicked.connect(self.start_processing)
left_layout.addWidget(self.btn_start)
self.progress_bar = QProgressBar()
left_layout.addWidget(self.progress_bar)
# Right Panel (Preview)
right_panel = QWidget()
right_layout = QVBoxLayout(right_panel)
main_layout.addWidget(right_panel)
right_layout.addWidget(QLabel("預覽 (點擊文件列表查看):"))
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.lbl_preview = QLabel()
self.lbl_preview.setAlignment(Qt.AlignCenter)
self.scroll_area.setWidget(self.lbl_preview)
right_layout.addWidget(self.scroll_area)
# Debounce timer for preview update to avoid lag
self.preview_timer = None
def add_files(self):
files, _ = QFileDialog.getOpenFileNames(self, "選擇圖片", "", "Images (*.png *.jpg *.jpeg *.bmp)")
if files:
self.image_files.extend(files)
self.update_file_list()
if not self.edit_output.text():
self.edit_output.setText(os.path.dirname(files[0]) + "/watermarked")
def add_folder(self):
folder = QFileDialog.getExistingDirectory(self, "選擇文件夾")
if folder:
for root, dirs, files in os.walk(folder):
for file in files:
if file.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp')):
self.image_files.append(os.path.join(root, file))
self.update_file_list()
if not self.edit_output.text():
self.edit_output.setText(folder + "/watermarked")
def clear_files(self):
self.image_files = []
self.update_file_list()
self.lbl_preview.clear()
def update_file_list(self):
self.list_files.clear()
for f in self.image_files:
self.list_files.addItem(os.path.basename(f))
if self.image_files:
self.list_files.setCurrentRow(0)
def browse_output(self):
folder = QFileDialog.getExistingDirectory(self, "選擇輸出目錄")
if folder:
self.edit_output.setText(folder)
def choose_color(self):
color = QColorDialog.getColor()
if color.isValid():
self.watermark_color = (color.red(), color.green(), color.blue())
self.lbl_color_preview.setStyleSheet(f"background-color: {color.name()}; border: 1px solid black;")
self.update_preview()
def update_preview_delayed(self):
# In a real app, use a QTimer to debounce.
# For simplicity here, just call update_preview directly,
# but keep method name to indicate intent if we add timer later.
self.update_preview()
def update_preview(self):
row = self.list_files.currentRow()
if row < 0 or row >= len(self.image_files):
return
file_path = self.image_files[row]
# Generate preview
try:
config = self.get_config()
# Use PIL to generate preview
with Image.open(file_path).convert("RGBA") as img:
# Resize for preview if too large
preview_max_size = 800
if img.width > preview_max_size or img.height > preview_max_size:
img.thumbnail((preview_max_size, preview_max_size))
# Apply watermark (Reuse logic? For now duplicate simplified logic for preview speed)
watermark = Image.new('RGBA', img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(watermark)
font_size = config['font_size']
# Scale font size relative to preview thumbnail
# Note: config['font_size'] is for the original image?
# Ideally we should scale it down. But font size is usually absolute pixels.
# If we scaled down the image, the font will look HUGE if we don't scale it too.
# So we need to know the original image size vs preview size.
# Let's read original size first
with Image.open(file_path) as orig_img:
orig_w, orig_h = orig_img.size
scale_factor = img.width / orig_w
scaled_font_size = int(font_size * scale_factor)
if scaled_font_size < 1: scaled_font_size = 1
try:
font = ImageFont.truetype("msyh.ttc", scaled_font_size)
except:
font = ImageFont.load_default()
try:
font = ImageFont.truetype("arial.ttf", scaled_font_size)
except:
pass
text = config['text']
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
max_dim = int(math.sqrt(text_width**2 + text_height**2))
txt_img = Image.new('RGBA', (max_dim, max_dim), (0, 0, 0, 0))
txt_draw = ImageDraw.Draw(txt_img)
text_x = (max_dim - text_width) // 2
text_y = (max_dim - text_height) // 2
color = config['color']
opacity = config['opacity']
fill_color = (color[0], color[1], color[2], int(255 * opacity))
txt_draw.text((text_x, text_y), text, font=font, fill=fill_color)
if config['rotation'] != 0:
txt_img = txt_img.rotate(config['rotation'], resample=Image.BICUBIC)
w_width, w_height = txt_img.size
if config['position'] == '平鋪 (Tile)':
scaled_spacing = int(config['spacing'] * scale_factor)
step_x = int(w_width + scaled_spacing)
step_y = int(w_height + scaled_spacing)
if step_x <= 0: step_x = w_width + 10
if step_y <= 0: step_y = w_height + 10
for y in range(0, img.height, step_y):
for x in range(0, img.width, step_x):
watermark.paste(txt_img, (x, y), txt_img)
else:
pos_x = 0
pos_y = 0
margin = int(20 * scale_factor)
position = config['position']
if '左' in position: pos_x = margin
elif '右' in position: pos_x = img.width - w_width - margin
else: pos_x = (img.width - w_width) // 2
if '上' in position: pos_y = margin
elif '下' in position: pos_y = img.height - w_height - margin
else: pos_y = (img.height - w_height) // 2
watermark.paste(txt_img, (pos_x, pos_y), txt_img)
out = Image.alpha_composite(img, watermark)
# Convert to QPixmap
if out.mode == "RGBA":
r, g, b, a = out.split()
out = Image.merge("RGBA", (b, g, r, a))
elif out.mode == "RGB":
r, g, b = out.split()
out = Image.merge("RGB", (b, g, r))
im2 = out.convert("RGBA")
data = im2.tobytes("raw", "RGBA")
qim = QImage(data, out.size[0], out.size[1], QImage.Format_ARGB32)
pixmap = QPixmap.fromImage(qim)
self.lbl_preview.setPixmap(pixmap)
except Exception as e:
print(f"Preview error: {e}")
def get_config(self):
return {
'text': self.edit_text.text(),
'font_size': self.spin_size.value(),
'opacity': self.slider_opacity.value() / 100.0,
'rotation': self.slider_rotation.value(),
'color': self.watermark_color,
'position': self.combo_pos.currentText(),
'spacing': self.spin_spacing.value()
}
def start_processing(self):
if not self.image_files:
QMessageBox.warning(self, "提示", "請先添加圖片!")
return
output_dir = self.edit_output.text()
if not output_dir:
QMessageBox.warning(self, "提示", "請選擇輸出目錄!")
return
self.btn_start.setEnabled(False)
self.progress_bar.setValue(0)
config = self.get_config()
self.worker = Worker(self.image_files, output_dir, config)
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.finished.connect(self.processing_finished)
self.worker.error.connect(self.processing_error)
self.worker.start()
def processing_finished(self, msg):
self.btn_start.setEnabled(True)
QMessageBox.information(self, "完成", msg)
def processing_error(self, msg):
self.btn_start.setEnabled(True)
QMessageBox.critical(self, "錯誤", msg)
if __name__ == "__main__":
app = QApplication(sys.argv)
# 設置全局字體,看起來更現(xiàn)代一點
font = QFont("Microsoft YaHei", 9)
app.setFont(font)
window = WatermarkApp()
window.show()
sys.exit(app.exec_())
總結
通過不到 400 行代碼,我們結合了 PyQt5 的交互能力和 Pillow 的圖像處理能力,開發(fā)出了一個實用的桌面工具。這個項目很好的展示了 Python 在自動化辦公和工具開發(fā)領域的優(yōu)勢。
擴展思路:
- 支持圖片水印(Logo)。
- 保存/加載配置模板,方便下次直接使用。
- 打包成 exe 文件(使用
pyinstaller),方便分享給沒有安裝 Python 的同事使用。
以上就是Python編寫一個圖片批量添加文字水印工具(附代碼)的詳細內(nèi)容,更多關于Python圖片批量添加文字水印的資料請關注腳本之家其它相關文章!
相關文章
pytorch保存和加載模型的方法及如何load部分參數(shù)
本文總結了pytorch中保存和加載模型的方法,以及在保存的模型文件與新定義的模型的參數(shù)不一一對應時,我們該如何加載模型參數(shù),對pytorch保存和加載模型相關知識感興趣的朋友一起看看吧2024-03-03
Python使用grequests(gevent+requests)并發(fā)發(fā)送請求過程解析
這篇文章主要介紹了Python使用grequests并發(fā)發(fā)送請求過程解析,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2019-09-09

