PyQt5 批量图片格式转换工具:支持拖拽目录与多图
这篇文章介绍一个基于 PyQt5 + Pillow 的桌面小工具,用来批量转换图片格式。它支持拖拽文件或目录、批量选择、多线程转换,界面简洁美观,适合日常素材整理与格式统一。

功能概览
- 支持拖拽目录与多张图片
- 支持 JPEG、PNG、BMP、WEBP、TIFF 等格式互转
- 输出目录可选,留空自动输出到原目录
- JPEG/WEBP 质量可调,避免过度压缩
- 转换过程中进度条可见,状态实时提示
- 自动避免覆盖原文件(重名自动加序号)
环境依赖与运行
安装依赖:
pip install PyQt5 Pillow
运行程序:
python main.py
使用方式
- 直接拖拽图片或文件夹到列表区域
- 也可以点击“添加图片/添加目录”
- 选择目标格式(JPEG/PNG/BMP/WEBP/TIFF)
- 需要时选择输出目录(不选则输出到原目录)
- 点击“开始转换”,等待完成提示
界面设计要点
界面重点放在“高效率 + 清晰反馈”:
- 统一蓝色主色调,按钮强调操作入口
- 列表区域支持拖拽,降低操作成本
- 进度条与状态文本同步变化,减少不确定感
- 输出路径留空策略减少不必要配置
关键实现思路
1. 拖拽支持
拖拽主要由自定义 QListWidget 完成,通过重写 dragEnterEvent、dragMoveEvent、dropEvent 接收文件与目录,再统一交给路径收集函数处理。
2. 批量图片收集
路径收集逻辑会递归遍历目录,只收集常见图片后缀(jpg/png/bmp/webp/tiff),避免无效文件进入列表。
3. 多线程转换
使用 QThread 将图片转换放到后台线程中执行,避免界面卡顿。主线程只更新进度条与状态文本。
4. 透明通道处理
当目标格式是 JPEG/WEBP 时,透明通道需要合成到纯色背景。这里默认使用白色背景,保证转换后图片不出现异常黑底。
5. 避免覆盖
如果输出路径已存在,会自动追加 _1、_2 等后缀,防止原文件被误覆盖。
结构说明
项目结构非常简单:
.
├── main.py
└── 博客.md
主要类与功能:
DropListWidget:负责拖拽交互ConversionWorker:执行批量转换并发送进度信号MainWindow:构建 UI,组织交互逻辑
完整代码
import os
import sys
from pathlib import Path
from PyQt5 import QtCore, QtGui, QtWidgets
from PIL import Image
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff", ".tif"}
FORMAT_LABELS = ["JPEG", "PNG", "BMP", "WEBP", "TIFF"]
def is_image_file(path):
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
def collect_images(paths):
results = []
for path in paths:
if os.path.isdir(path):
for root, _, files in os.walk(path):
for name in files:
file_path = os.path.join(root, name)
if is_image_file(file_path):
results.append(file_path)
elif os.path.isfile(path) and is_image_file(path):
results.append(path)
return results
def ensure_unique_path(path):
if not os.path.exists(path):
return path
base, ext = os.path.splitext(path)
index = 1
while True:
candidate = f"{base}_{index}{ext}"
if not os.path.exists(candidate):
return candidate
index += 1
def to_rgb(image, background_color="#FFFFFF"):
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
background = Image.new("RGBA", image.size, background_color)
background.paste(image, mask=image.split()[-1])
return background.convert("RGB")
if image.mode != "RGB":
return image.convert("RGB")
return image
class DropListWidget(QtWidgets.QListWidget):
itemsDropped = QtCore.pyqtSignal(list)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setAlternatingRowColors(True)
self.setDragDropMode(QtWidgets.QAbstractItemView.DropOnly)
self.placeholder_label = QtWidgets.QLabel("拖拽图片或文件夹到此处", self)
self.placeholder_label.setAlignment(QtCore.Qt.AlignCenter)
self.placeholder_label.setStyleSheet("color: #93a1bf;")
self.model().rowsInserted.connect(self.update_placeholder)
self.model().rowsRemoved.connect(self.update_placeholder)
self.model().modelReset.connect(self.update_placeholder)
QtCore.QTimer.singleShot(0, self.update_placeholder)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
super().dragMoveEvent(event)
def dropEvent(self, event):
if event.mimeData().hasUrls():
paths = [url.toLocalFile() for url in event.mimeData().urls()]
self.itemsDropped.emit(paths)
event.acceptProposedAction()
else:
super().dropEvent(event)
def resizeEvent(self, event):
super().resizeEvent(event)
self.placeholder_label.setGeometry(self.viewport().rect())
def update_placeholder(self, *args):
self.placeholder_label.setVisible(self.count() == 0)
class ConversionWorker(QtCore.QThread):
progressChanged = QtCore.pyqtSignal(int, int)
messageChanged = QtCore.pyqtSignal(str)
finishedWithResult = QtCore.pyqtSignal(int, int)
def __init__(self, files, output_dir, target_format, quality):
super().__init__()
self.files = files
self.output_dir = output_dir
self.target_format = target_format
self.quality = quality
def run(self):
success = 0
failed = 0
total = len(self.files)
for index, path in enumerate(self.files, start=1):
try:
with Image.open(path) as image:
ext = ".jpg" if self.target_format == "JPEG" else f".{self.target_format.lower()}"
base_name = os.path.splitext(os.path.basename(path))[0]
if self.output_dir:
os.makedirs(self.output_dir, exist_ok=True)
output_path = os.path.join(self.output_dir, base_name + ext)
else:
output_path = os.path.join(os.path.dirname(path), base_name + ext)
output_path = ensure_unique_path(output_path)
save_kwargs = {}
if self.target_format in {"JPEG", "WEBP"}:
save_kwargs["quality"] = self.quality
save_image = to_rgb(image)
else:
save_image = image
save_image.save(output_path, self.target_format, **save_kwargs)
success += 1
self.messageChanged.emit(f"已转换:{os.path.basename(path)}")
except Exception as exc:
failed += 1
self.messageChanged.emit(f"失败:{os.path.basename(path)} - {exc}")
self.progressChanged.emit(index, total)
self.finishedWithResult.emit(success, failed)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("批量图片格式转换 作者:小庄-Python学习")
self.setMinimumSize(860, 560)
self.worker = None
self.init_ui()
def init_ui(self):
central = QtWidgets.QWidget()
self.setCentralWidget(central)
main_layout = QtWidgets.QVBoxLayout(central)
main_layout.setContentsMargins(24, 24, 24, 24)
main_layout.setSpacing(16)
title = QtWidgets.QLabel("批量图片格式转换")
title.setFont(QtGui.QFont("Microsoft YaHei", 18, QtGui.QFont.Bold))
subtitle = QtWidgets.QLabel("支持拖拽目录或多张图片,快速转换格式")
subtitle.setFont(QtGui.QFont("Microsoft YaHei", 10))
header_layout = QtWidgets.QVBoxLayout()
header_layout.addWidget(title)
header_layout.addWidget(subtitle)
main_layout.addLayout(header_layout)
action_layout = QtWidgets.QHBoxLayout()
self.add_files_button = QtWidgets.QPushButton("添加图片")
self.add_dir_button = QtWidgets.QPushButton("添加目录")
self.remove_button = QtWidgets.QPushButton("移除所选")
self.clear_button = QtWidgets.QPushButton("清空列表")
action_layout.addWidget(self.add_files_button)
action_layout.addWidget(self.add_dir_button)
action_layout.addWidget(self.remove_button)
action_layout.addWidget(self.clear_button)
action_layout.addStretch()
main_layout.addLayout(action_layout)
self.list_widget = DropListWidget()
self.list_widget.setMinimumHeight(240)
main_layout.addWidget(self.list_widget)
settings_layout = QtWidgets.QGridLayout()
settings_layout.setHorizontalSpacing(16)
settings_layout.setVerticalSpacing(12)
output_label = QtWidgets.QLabel("输出目录")
self.output_edit = QtWidgets.QLineEdit()
self.output_edit.setPlaceholderText("留空则输出到原目录")
self.output_button = QtWidgets.QPushButton("选择目录")
format_label = QtWidgets.QLabel("目标格式")
self.format_combo = QtWidgets.QComboBox()
self.format_combo.addItems(FORMAT_LABELS)
quality_label = QtWidgets.QLabel("质量")
self.quality_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.quality_slider.setRange(50, 100)
self.quality_slider.setValue(92)
self.quality_value = QtWidgets.QLabel("92")
self.quality_value.setFixedWidth(36)
settings_layout.addWidget(output_label, 0, 0)
settings_layout.addWidget(self.output_edit, 0, 1)
settings_layout.addWidget(self.output_button, 0, 2)
settings_layout.addWidget(format_label, 1, 0)
settings_layout.addWidget(self.format_combo, 1, 1)
settings_layout.addWidget(quality_label, 2, 0)
quality_row = QtWidgets.QHBoxLayout()
quality_row.addWidget(self.quality_slider)
quality_row.addWidget(self.quality_value)
settings_layout.addLayout(quality_row, 2, 1, 1, 2)
main_layout.addLayout(settings_layout)
bottom_layout = QtWidgets.QHBoxLayout()
self.convert_button = QtWidgets.QPushButton("开始转换")
self.convert_button.setFixedHeight(42)
self.progress_bar = QtWidgets.QProgressBar()
self.progress_bar.setTextVisible(True)
bottom_layout.addWidget(self.convert_button)
bottom_layout.addWidget(self.progress_bar, 1)
main_layout.addLayout(bottom_layout)
self.status_label = QtWidgets.QLabel("就绪")
main_layout.addWidget(self.status_label)
self.apply_styles()
self.wire_events()
def apply_styles(self):
self.setStyleSheet(
"""
QWidget {
background-color: #f5f7fb;
color: #1f2a44;
font-family: Microsoft YaHei;
font-size: 11pt;
}
QLineEdit, QListWidget, QComboBox {
background-color: #ffffff;
border: 1px solid #d7dce6;
border-radius: 8px;
padding: 6px 10px;
}
QPushButton {
background-color: #2f6fed;
color: white;
border-radius: 8px;
padding: 8px 16px;
border: none;
}
QPushButton:hover {
background-color: #245ad1;
}
QPushButton:disabled {
background-color: #98b4f3;
}
QListWidget::item {
padding: 6px;
}
QListWidget::item:selected {
background-color: #dbe7ff;
color: #1f2a44;
}
QProgressBar {
background-color: #ffffff;
border: 1px solid #d7dce6;
border-radius: 8px;
text-align: center;
}
QProgressBar::chunk {
background-color: #2f6fed;
border-radius: 8px;
}
"""
)
def wire_events(self):
self.add_files_button.clicked.connect(self.add_files)
self.add_dir_button.clicked.connect(self.add_directory)
self.remove_button.clicked.connect(self.remove_selected)
self.clear_button.clicked.connect(self.clear_list)
self.output_button.clicked.connect(self.choose_output_dir)
self.format_combo.currentTextChanged.connect(self.update_quality_state)
self.quality_slider.valueChanged.connect(self.update_quality_label)
self.convert_button.clicked.connect(self.start_conversion)
self.list_widget.itemsDropped.connect(self.add_paths)
def add_files(self):
filters = "图片文件 (*.jpg *.jpeg *.png *.bmp *.webp *.tif *.tiff)"
paths, _ = QtWidgets.QFileDialog.getOpenFileNames(self, "选择图片", "", filters)
if paths:
self.add_paths(paths)
def add_directory(self):
path = QtWidgets.QFileDialog.getExistingDirectory(self, "选择目录")
if path:
self.add_paths([path])
def add_paths(self, paths):
files = collect_images(paths)
if not files:
self.status_label.setText("未找到可处理的图片")
return
existing = {self.list_widget.item(i).data(QtCore.Qt.UserRole) for i in range(self.list_widget.count())}
added = 0
for path in files:
if path in existing:
continue
item = QtWidgets.QListWidgetItem(path)
item.setData(QtCore.Qt.UserRole, path)
self.list_widget.addItem(item)
added += 1
self.status_label.setText(f"已添加 {added} 张图片")
def remove_selected(self):
for item in self.list_widget.selectedItems():
self.list_widget.takeItem(self.list_widget.row(item))
self.status_label.setText("已移除所选图片")
def clear_list(self):
self.list_widget.clear()
self.status_label.setText("列表已清空")
def choose_output_dir(self):
path = QtWidgets.QFileDialog.getExistingDirectory(self, "选择输出目录")
if path:
self.output_edit.setText(path)
def update_quality_state(self, text):
enabled = text in {"JPEG", "WEBP"}
self.quality_slider.setEnabled(enabled)
self.quality_value.setEnabled(enabled)
def update_quality_label(self, value):
self.quality_value.setText(str(value))
def start_conversion(self):
files = [self.list_widget.item(i).data(QtCore.Qt.UserRole) for i in range(self.list_widget.count())]
if not files:
self.status_label.setText("请先添加图片")
return
output_dir = self.output_edit.text().strip()
target_format = self.format_combo.currentText()
quality = self.quality_slider.value()
self.progress_bar.setRange(0, len(files))
self.progress_bar.setValue(0)
self.set_controls_enabled(False)
self.worker = ConversionWorker(files, output_dir, target_format, quality)
self.worker.progressChanged.connect(self.update_progress)
self.worker.messageChanged.connect(self.update_status)
self.worker.finishedWithResult.connect(self.finish_conversion)
self.worker.start()
def set_controls_enabled(self, enabled):
widgets = [
self.add_files_button,
self.add_dir_button,
self.remove_button,
self.clear_button,
self.output_button,
self.format_combo,
self.quality_slider,
self.convert_button,
]
for widget in widgets:
widget.setEnabled(enabled)
def update_progress(self, current, total):
self.progress_bar.setMaximum(total)
self.progress_bar.setValue(current)
def update_status(self, message):
self.status_label.setText(message)
def finish_conversion(self, success, failed):
self.set_controls_enabled(True)
self.status_label.setText(f"完成:成功 {success} 张,失败 {failed} 张")
def main():
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
常见问题
Q:转换速度慢怎么办?
A:转换速度主要取决于图片大小和数量。建议先筛选少量测试后再批量处理。
Q:图片颜色变化或透明丢失?
A:JPEG 不支持透明通道,转换为 JPEG 时透明会被合成到白色背景。
Q:能否保持原目录结构?
A:目前版本输出到同一目录。可以扩展为保留目录结构或在 UI 增加选项。
可以扩展的方向
- 追加“保持目录结构”与“覆盖原文件”的选项
- 支持调整缩放尺寸与批量重命名
- 增加图片预览或缩略图模式
- 支持预设输出尺寸与压缩策略
如果你希望继续扩展功能,可以从 ConversionWorker 和 MainWindow 的交互逻辑入手,增加新的配置项即可。