基于 PyQt5 的批量图片转 GIF 工具:支持自定义顺序与间隔时间
在日常办公或自媒体创作中,我们经常需要将多张静态图片(如截屏、摄影作品或分步演示图)合成为一个 GIF 动态图。市面上虽然有很多在线工具,但往往存在隐私泄露风险、上传限制或强制水印。
今天,我们将使用 Python 的 PyQt5 和 Pillow 库,从零开发一个界面精美、功能强大的桌面端 GIF 生成工具。该工具支持图片批量拖拽、自由调整播放顺序以及自定义帧间隔时间。

核心功能亮点
- 高颜值界面:采用现代简约设计,支持高 DPI 缩放,视觉体验更佳。
- 极简交互:支持直接从资源管理器拖拽图片到窗口中。
- 顺序可控:在列表中可以通过鼠标拖拽自由调整图片的先后顺序。
- 时间自定义:精确设置每帧图片之间的间隔时间(毫秒级)。
- 自动适配:自动将所有图片缩放至与第一张图片一致的尺寸,防止合成失败。
- 跨平台支持:基于 Python 开发,Windows、macOS 和 Linux 均可运行。
准备工作
在开始之前,请确保你的电脑已安装 Python,并安装以下依赖库:
pip install PyQt5 Pillow
- PyQt5:负责构建图形用户界面。
- Pillow (PIL):负责图片的读取、缩放和 GIF 合成。
代码实现解析
1. 现代化的界面设计 (QSS)
为了让工具看起来更专业,我们使用了 Qt Style Sheets (QSS) 进行深度美化:
- 圆角矩形:按钮、输入框和列表项均采用圆角设计。
- 阴影效果:为窗口主容器添加了柔和的阴影。
- 交互反馈:悬停、选中状态下有明显的颜色变化。
STYLE_SHEET = """
QMainWindow { background-color: #f5f6fa; }
QWidget#CentralWidget { background-color: #ffffff; border-radius: 10px; }
QPushButton#PrimaryBtn { background-color: #44bd32; color: white; border: none; font-weight: bold; }
...
"""
2. 实现支持拖拽的图片列表
为了提升用户体验,我们自定义了一个 ImageListWidget 类,继承自 QListWidget。通过重写 dragEnterEvent 和 dropEvent,实现了文件拖入功能。同时开启了 InternalMove 模式,让用户可以直接在列表内拖拽调整顺序。
class ImageListWidget(QListWidget):
def dropEvent(self, event):
if event.mimeData().hasUrls():
# 处理外部拖入的文件
for url in event.mimeData().urls():
file_path = url.toLocalFile()
if file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.webp')):
self.add_image_item(file_path)
else:
# 处理列表内部的顺序调整
super().dropEvent(event)
3. GIF 合成的核心逻辑
利用 Pillow 的 Image.save 方法,我们可以轻松实现 GIF 的合成。关键参数包括 save_all=True(保存所有帧)、append_images(后续帧列表)和 duration(帧间隔时间)。
def generate_gif(self):
# ... 获取文件路径和列表顺序 ...
frames = []
for i in range(count):
img = Image.open(file_path)
# 统一尺寸(以第一张图为准)
if not frames:
base_size = img.size
else:
img = img.resize(base_size, Image.Resampling.LANCZOS)
frames.append(img.convert('RGBA'))
# 保存为 GIF
frames[0].save(
save_path,
save_all=True,
append_images=frames[1:],
duration=self.spin_duration.value(),
loop=0,
disposal=2 # 清除背景,防止透明图层重叠
)
如何使用
- 添加图片:点击“添加图片”按钮选择文件,或直接从文件夹将多张图片拖入窗口列表。
- 调整顺序:在列表中,长按某张图片并上下拖动,即可调整它在 GIF 中的出现顺序。
- 设置时间:在底部的“间隔时间”框中输入毫秒数(例如 200ms 表示每秒播放 5 帧)。
- 生成导出:点击绿色的“生成 GIF”按钮,选择保存位置,稍等片刻即可完成。
完整代码
import sys
import os
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QListWidget, QListWidgetItem, QFileDialog,
QLabel, QSpinBox, QMessageBox, QFrame, QGraphicsDropShadowEffect)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QIcon, QPixmap, QFont, QColor
from PIL import Image
# 全局样式表 - 现代简约风格
STYLE_SHEET = """
QMainWindow {
background-color: #f5f6fa;
}
QWidget#CentralWidget {
background-color: #ffffff;
border-radius: 10px;
}
QLabel#HeaderLabel {
font-size: 20px;
font-weight: bold;
color: #2f3640;
margin-bottom: 10px;
}
QLabel#InfoLabel {
color: #7f8c8d;
font-size: 13px;
}
QListWidget {
background-color: #f9f9f9;
border: 2px dashed #dcdde1;
border-radius: 8px;
padding: 5px;
}
QListWidget::item {
background-color: #ffffff;
border-radius: 5px;
margin-bottom: 5px;
padding: 10px;
border: 1px solid #eeeeee;
}
QListWidget::item:selected {
background-color: #e3f2fd;
border: 1px solid #2196f3;
color: #1976d2;
}
QPushButton {
background-color: #ffffff;
border: 1px solid #dcdde1;
border-radius: 6px;
padding: 8px 15px;
font-weight: 500;
color: #2f3640;
}
QPushButton:hover {
background-color: #f1f2f6;
border-color: #b2bec3;
}
QPushButton#PrimaryBtn {
background-color: #44bd32;
color: white;
border: none;
font-size: 14px;
font-weight: bold;
}
QPushButton#PrimaryBtn:hover {
background-color: #4cd137;
}
QPushButton#DangerBtn {
color: #e84118;
}
QPushButton#DangerBtn:hover {
background-color: #fff5f5;
border-color: #fab1a0;
}
QSpinBox {
padding: 5px;
border: 1px solid #dcdde1;
border-radius: 4px;
min-width: 80px;
}
"""
class ImageListWidget(QListWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self.setDragEnabled(True)
self.setDragDropMode(QListWidget.InternalMove)
self.setIconSize(QSize(80, 80))
self.setSpacing(8)
self.setSelectionMode(QListWidget.ExtendedSelection)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.accept()
else:
super().dragEnterEvent(event)
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.accept()
else:
super().dragMoveEvent(event)
def dropEvent(self, event):
if event.mimeData().hasUrls():
event.accept()
for url in event.mimeData().urls():
file_path = url.toLocalFile()
if file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.webp')):
self.add_image_item(file_path)
else:
super().dropEvent(event)
def add_image_item(self, file_path):
# 检查是否已存在
for i in range(self.count()):
if self.item(i).data(Qt.UserRole) == file_path:
return
item = QListWidgetItem(os.path.basename(file_path))
item.setData(Qt.UserRole, file_path)
item.setFont(QFont("Segoe UI", 10))
# 加载缩略图
pixmap = QPixmap(file_path)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap.scaled(160, 160, Qt.KeepAspectRatio, Qt.SmoothTransformation)))
self.addItem(item)
class GifMakerApp(QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('GIF Master - 批量图片转动态图 作者:小庄-Python学习')
self.setMinimumSize(700, 600)
self.setStyleSheet(STYLE_SHEET)
# 主容器
container = QWidget()
container.setObjectName("CentralWidget")
self.setCentralWidget(container)
# 主布局
main_layout = QVBoxLayout(container)
main_layout.setContentsMargins(30, 30, 30, 30)
main_layout.setSpacing(20)
# 头部区域
header_box = QVBoxLayout()
title_label = QLabel('GIF Master')
title_label.setObjectName("HeaderLabel")
info_label = QLabel('将图片拖入下方列表,自由排序并生成动态图')
info_label.setObjectName("InfoLabel")
header_box.addWidget(title_label)
header_box.addWidget(info_label)
main_layout.addLayout(header_box)
# 图片列表区域 (放在一个有阴影的 Frame 里)
list_frame = QFrame()
list_layout = QVBoxLayout(list_frame)
list_layout.setContentsMargins(0, 0, 0, 0)
self.image_list = ImageListWidget()
list_layout.addWidget(self.image_list)
main_layout.addWidget(list_frame)
# 底部控制区
bottom_box = QVBoxLayout()
bottom_box.setSpacing(15)
# 按钮行 1: 列表操作
list_ops_layout = QHBoxLayout()
self.btn_add = QPushButton(' 添加图片')
self.btn_add.setIcon(self.style().standardIcon(60)) # SP_DirIcon
self.btn_add.clicked.connect(self.add_images)
self.btn_remove = QPushButton(' 移除选中')
self.btn_remove.setObjectName("DangerBtn")
self.btn_remove.clicked.connect(self.remove_selected)
self.btn_clear = QPushButton(' 清空全部')
self.btn_clear.setObjectName("DangerBtn")
self.btn_clear.clicked.connect(self.clear_list)
list_ops_layout.addWidget(self.btn_add)
list_ops_layout.addStretch()
list_ops_layout.addWidget(self.btn_remove)
list_ops_layout.addWidget(self.btn_clear)
bottom_box.addLayout(list_ops_layout)
# 分割线
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setStyleSheet("background-color: #eeeeee;")
bottom_box.addWidget(line)
# 按钮行 2: 参数设置与生成
settings_layout = QHBoxLayout()
settings_layout.addWidget(QLabel('帧间隔 (ms):'))
self.spin_duration = QSpinBox()
self.spin_duration.setRange(10, 5000)
self.spin_duration.setValue(200)
self.spin_duration.setSingleStep(50)
settings_layout.addWidget(self.spin_duration)
settings_layout.addStretch()
self.btn_generate = QPushButton(' 立即生成 GIF')
self.btn_generate.setObjectName("PrimaryBtn")
self.btn_generate.setMinimumWidth(150)
self.btn_generate.clicked.connect(self.generate_gif)
settings_layout.addWidget(self.btn_generate)
bottom_box.addLayout(settings_layout)
main_layout.addLayout(bottom_box)
# 添加阴影效果
shadow = QGraphicsDropShadowEffect()
shadow.setBlurRadius(20)
shadow.setColor(QColor(0, 0, 0, 30))
shadow.setOffset(0, 5)
container.setGraphicsEffect(shadow)
def add_images(self):
files, _ = QFileDialog.getOpenFileNames(
self, '选择图片', '',
'Images (*.png *.jpg *.jpeg *.bmp *.webp)'
)
if files:
for file_path in files:
self.image_list.add_image_item(file_path)
def remove_selected(self):
selected_items = self.image_list.selectedItems()
if not selected_items:
return
for item in selected_items:
self.image_list.takeItem(self.image_list.row(item))
def clear_list(self):
if self.image_list.count() == 0:
return
reply = QMessageBox.question(self, '确认', '确定要清空图片列表吗?',
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
self.image_list.clear()
def generate_gif(self):
count = self.image_list.count()
if count < 2:
QMessageBox.warning(self, '提示', '请至少添加两张图片以生成 GIF。')
return
save_path, _ = QFileDialog.getSaveFileName(self, '保存 GIF', 'output.gif', 'GIF (*.gif)')
if not save_path:
return
try:
frames = []
base_size = None
for i in range(count):
item = self.image_list.item(i)
file_path = item.data(Qt.UserRole)
img = Image.open(file_path)
# 统一尺寸(以第一张图为准)
if i == 0:
base_size = img.size
else:
if img.size != base_size:
img = img.resize(base_size, Image.Resampling.LANCZOS)
# 转换模式
if img.mode != 'RGBA':
img = img.convert('RGBA')
frames.append(img)
duration = self.spin_duration.value()
# 保存 GIF
frames[0].save(
save_path,
save_all=True,
append_images=frames[1:],
duration=duration,
loop=0,
disposal=2
)
QMessageBox.information(self, '成功', f'✨ GIF 已完美生成!\n保存路径:{save_path}')
except Exception as e:
QMessageBox.critical(self, '错误', f'生成失败: {str(e)}')
if __name__ == '__main__':
# 启用高 DPI 支持
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)
app = QApplication(sys.argv)
app.setFont(QFont("Segoe UI", 9))
ex = GifMakerApp()
ex.show()
sys.exit(app.exec_())
总结
这款工具不仅解决了图片转 GIF 的基础需求,更通过 PyQt5 强大的交互能力实现了“所见即所得”的排序功能。
进阶思路:
- 增加图片预览窗口。
- 支持设置 GIF 的循环次数。
- 添加图片的滤镜处理功能。
希望这个小工具能为你带来便利!如果有任何改进建议,欢迎交流。