200字
PyQt5 批量图片格式转换工具:支持拖拽目录与多图
2026-03-17
2026-03-17

PyQt5 批量图片格式转换工具:支持拖拽目录与多图

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

PixPin_2026-03-17_10-03-59.png

功能概览

  • 支持拖拽目录与多张图片
  • 支持 JPEG、PNG、BMP、WEBP、TIFF 等格式互转
  • 输出目录可选,留空自动输出到原目录
  • JPEG/WEBP 质量可调,避免过度压缩
  • 转换过程中进度条可见,状态实时提示
  • 自动避免覆盖原文件(重名自动加序号)

环境依赖与运行

安装依赖:

pip install PyQt5 Pillow

运行程序:

python main.py

使用方式

  1. 直接拖拽图片或文件夹到列表区域
  2. 也可以点击“添加图片/添加目录”
  3. 选择目标格式(JPEG/PNG/BMP/WEBP/TIFF)
  4. 需要时选择输出目录(不选则输出到原目录)
  5. 点击“开始转换”,等待完成提示

界面设计要点

界面重点放在“高效率 + 清晰反馈”:

  • 统一蓝色主色调,按钮强调操作入口
  • 列表区域支持拖拽,降低操作成本
  • 进度条与状态文本同步变化,减少不确定感
  • 输出路径留空策略减少不必要配置

关键实现思路

1. 拖拽支持

拖拽主要由自定义 QListWidget 完成,通过重写 dragEnterEventdragMoveEventdropEvent 接收文件与目录,再统一交给路径收集函数处理。

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 增加选项。

可以扩展的方向

  • 追加“保持目录结构”与“覆盖原文件”的选项
  • 支持调整缩放尺寸与批量重命名
  • 增加图片预览或缩略图模式
  • 支持预设输出尺寸与压缩策略

如果你希望继续扩展功能,可以从 ConversionWorkerMainWindow 的交互逻辑入手,增加新的配置项即可。

PyQt5 批量图片格式转换工具:支持拖拽目录与多图
作者
一晌小贪欢
发表于
2026-03-17
License
CC BY-NC-SA 4.0

评论