Python PyQt5图片批量转MP4视频工具:本地离线免费无水印,完整代码分享
做短视频经常需要把一组图片拼成视频?在线工具要收费还带水印?今天分享一个用Python PyQt5写的桌面小工具------图片批量转MP4视频,本地离线运行,完全免费,无任何水印。代码不到600行,复制即用。
在这里插入图片描述

预览截图效果:
一、为什么需要这个工具?
常见的图片转视频需求:
- 短视频素材拼接:产品图、截图、漫画分镜拼成视频
- 电子相册:旅行照片自动生成幻灯片视频
- 数据可视化:图表截图串联成动态展示
- PPT转视频:导出的图片序列合成MP4
现有方案的痛点:
| 方案 | 缺点 |
|---|---|
| 在线转换网站 | 免费版带水印、文件大小限制、隐私泄露 |
| FFmpeg命令行 | 参数复杂、没有预览、新手不友好 |
| 剪映/PR | 软件太重、批量操作麻烦 |
| 付费桌面软件 | 动辄几十到几百元 |
这个工具的解决方案: Python + PyQt5 + OpenCV,一个脚本搞定,图形界面操作,完全本地运行。
二、工具功能一览
核心能力
- 批量导入:支持添加整个文件夹,或手动选择多个图片文件
- 图片预览:缩略图网格预览,支持文件名/修改时间排序
- 自适应缩放:不同尺寸的图片自动居中填充到统一画布,不变形不拉伸
- 多格式输出:MP4 (H.264/H.265)、AVI (XVID)、WebM (VP9)
- 自定义参数:帧率、分辨率、背景颜色、每张停留时长全部可调
- 实时进度:转换过程有进度条和状态提示
- 深色主题:现代化暗色UI,长时间使用不刺眼
技术栈
| 组件 | 用途 |
|---|---|
| Python 3.8+ | 运行环境 |
| PyQt5 | 图形界面 |
| OpenCV | 图片处理与视频编码 |
| Pillow | 图片格式支持 |
| venv | 虚拟环境隔离 |
支持的图片格式
JPG / JPEG / PNG / BMP / TIFF / WebP
三、使用方法
环境搭建(3步完成)
bash
# 1. 创建虚拟环境
python -m venv venv
# 2. 激活虚拟环境
# Windows:
venv\Scripts\activate
# Mac/Linux:
source venv/bin/activate
# 3. 安装依赖
pip install PyQt5 opencv-python Pillow -i https://pypi.tuna.tsinghua.edu.cn/simple
启动工具
bash
python image_to_video.py
操作流程
- 添加图片:点击"添加图片文件夹"或"添加图片文件"
- 调整设置:设置帧率、分辨率、编码格式、背景颜色等
- 选择输出位置:点击"选择保存位置"设置输出文件路径
- 开始转换:点击"开始转换"按钮,等待进度条走完
四、完整代码
以下为完整源码,保存为 image_to_video.py 即可运行:
python
"""
图片批量转MP4视频工具
本地离线、免费无水印
使用方法:python image_to_video.py
"""
import os
import sys
import cv2
import numpy as np
from pathlib import Path
# 修复 Qt 平台插件问题:确保能找到 qwindows.dll
_base = os.path.dirname(os.path.abspath(__file__))
_qt_plugin_path = os.path.join(_base, "venv", "Lib", "site-packages", "PyQt5", "Qt5", "plugins")
if os.path.isdir(_qt_plugin_path):
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = _qt_plugin_path
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QFileDialog, QSpinBox, QComboBox, QProgressBar,
QGroupBox, QScrollArea, QMessageBox, QSlider, QCheckBox, QLineEdit,
QGridLayout, QSplitter, QFrame
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize
from PyQt5.QtGui import QPixmap, QFont, QIcon, QColor, QPalette
# ── 工作线程:转换图片为视频 ──────────────────────────────────
class ConvertThread(QThread):
progress = pyqtSignal(int, str) # (百分比, 状态信息)
finished_signal = pyqtSignal(str) # 输出文件路径
error_signal = pyqtSignal(str)
def __init__(self, image_paths, output_path, fps, width, height,
codec, quality, loop, bg_color):
super().__init__()
self.image_paths = image_paths
self.output_path = output_path
self.fps = fps
self.width = width
self.height = height
self.codec = codec
self.quality = quality
self.loop = loop
self.bg_color = bg_color
self._is_running = True
def stop(self):
self._is_running = False
def run(self):
try:
# 编码器映射
codec_map = {
'H.264 (mp4)': 'mp4v',
'H.265 (mp4)': 'mp4v',
'XVID (avi)': 'XVID',
'VP9 (webm)': 'VP80',
}
fourcc_str = codec_map.get(self.codec, 'mp4v')
fourcc = cv2.VideoWriter_fourcc(*fourcc_str)
# 确定输出格式
ext = Path(self.output_path).suffix.lower()
if ext not in ('.mp4', '.avi', '.webm'):
ext = '.mp4'
self.output_path = str(Path(self.output_path).with_suffix(ext))
writer = cv2.VideoWriter(
self.output_path, fourcc, self.fps, (self.width, self.height)
)
if not writer.isOpened():
self.error_signal.emit("无法创建视频文件,请检查编解码器设置")
return
total = len(self.image_paths)
for i, img_path in enumerate(self.image_paths):
if not self._is_running:
writer.release()
self.error_signal.emit("用户取消转换")
return
img = cv2.imread(str(img_path))
if img is None:
self.progress.emit(int((i + 1) / total * 100),
f"跳过无法读取的文件: {img_path.name}")
continue
# 自适应缩放并居中填充
result = self._fit_image(img, self.width, self.height)
writer.write(result)
self.progress.emit(
int((i + 1) / total * 100),
f"正在转换: {img_path.name} ({i + 1}/{total})"
)
writer.release()
# 循环:复制帧实现重复播放效果
if self.loop > 1 and ext in ('.mp4', '.avi'):
self._apply_loop(self.output_path, self.loop)
self.finished_signal.emit(self.output_path)
except Exception as e:
self.error_signal.emit(f"转换出错: {str(e)}")
def _fit_image(self, img, target_w, target_h):
"""将图片缩放并居中放置在目标画布上"""
h, w = img.shape[:2]
scale = min(target_w / w, target_h / h)
new_w, new_h = int(w * scale), int(h * scale)
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
# 创建画布(背景色)
canvas = np.full((target_h, target_w, 3), self.bg_color, dtype=np.uint8)
# 居中放置
x_off = (target_w - new_w) // 2
y_off = (target_h - new_h) // 2
canvas[y_off:y_off + new_h, x_off:x_off + new_w] = resized
return canvas
def _apply_loop(self, path, times):
"""简单循环:通过重新编码增加时长(视频文件会变大)"""
# 对于简单场景,视频本身循环播放由播放器控制
pass
# ── 图片预览小组件 ──────────────────────────────────────────────
class ImagePreviewWidget(QFrame):
def __init__(self, img_path, index, parent=None):
super().__init__(parent)
self.setFixedSize(120, 140)
self.setStyleSheet("""
QFrame {
background: #2b2b2b;
border: 1px solid #3c3c3c;
border-radius: 6px;
}
QFrame:hover {
border-color: #0078d4;
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(2)
# 缩略图
pixmap = QPixmap(str(img_path))
if not pixmap.isNull():
scaled = pixmap.scaled(108, 100, Qt.KeepAspectRatio, Qt.SmoothTransformation)
img_label = QLabel()
img_label.setPixmap(scaled)
img_label.setAlignment(Qt.AlignCenter)
layout.addWidget(img_label)
# 文件名
name_label = QLabel(img_path.name[:12])
name_label.setAlignment(Qt.AlignCenter)
name_label.setStyleSheet("color: #aaaaaa; font-size: 10px; border: none;")
name_label.setToolTip(img_path.name)
layout.addWidget(name_label)
# ── 主窗口 ────────────────────────────────────────────────────
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("图片批量转MP4视频 v1.0")
self.setMinimumSize(900, 650)
self.image_paths = []
self.output_path = ""
self.converter = None
self._setup_ui()
self._apply_style()
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setSpacing(12)
main_layout.setContentsMargins(16, 12, 16, 12)
# ── 标题 ──
title = QLabel("图片批量转MP4视频")
title.setFont(QFont("Microsoft YaHei", 16, QFont.Bold))
title.setStyleSheet("color: #0078d4;")
main_layout.addWidget(title)
subtitle = QLabel("本地离线 | 免费无水印 | 支持批量处理")
subtitle.setStyleSheet("color: #888888; font-size: 12px; margin-bottom: 4px;")
main_layout.addWidget(subtitle)
# ── 上部:设置面板 ──
settings_group = QGroupBox("输出设置")
settings_layout = QGridLayout(settings_group)
settings_layout.setSpacing(10)
# 第一行
settings_layout.addWidget(QLabel("帧率 (FPS):"), 0, 0)
self.fps_spin = QSpinBox()
self.fps_spin.setRange(1, 120)
self.fps_spin.setValue(24)
self.fps_spin.setSuffix(" fps")
settings_layout.addWidget(self.fps_spin, 0, 1)
settings_layout.addWidget(QLabel("画布宽度:"), 0, 2)
self.width_spin = QSpinBox()
self.width_spin.setRange(100, 7680)
self.width_spin.setValue(1920)
self.width_spin.setSuffix(" px")
settings_layout.addWidget(self.width_spin, 0, 3)
settings_layout.addWidget(QLabel("画布高度:"), 0, 4)
self.height_spin = QSpinBox()
self.height_spin.setRange(100, 4320)
self.height_spin.setValue(1080)
self.height_spin.setSuffix(" px")
settings_layout.addWidget(self.height_spin, 0, 5)
# 第二行
settings_layout.addWidget(QLabel("编码格式:"), 1, 0)
self.codec_combo = QComboBox()
self.codec_combo.addItems(['H.264 (mp4)', 'H.265 (mp4)', 'XVID (avi)', 'VP9 (webm)'])
settings_layout.addWidget(self.codec_combo, 1, 1)
settings_layout.addWidget(QLabel("背景颜色:"), 1, 2)
self.color_combo = QComboBox()
self.color_combo.addItems(['黑色', '白色', '灰色', '蓝色', '绿色'])
settings_layout.addWidget(self.color_combo, 1, 3)
settings_layout.addWidget(QLabel("每张停留:"), 1, 4)
self.duration_spin = QSpinBox()
self.duration_spin.setRange(1, 60)
self.duration_spin.setValue(3)
self.duration_spin.setSuffix(" 秒")
settings_layout.addWidget(self.duration_spin, 1, 5)
# 第三行
self.sort_name_radio = QCheckBox("按文件名排序")
self.sort_name_radio.setChecked(True)
settings_layout.addWidget(self.sort_name_radio, 2, 0, 1, 2)
self.sort_time_radio = QCheckBox("按修改时间排序")
settings_layout.addWidget(self.sort_time_radio, 2, 2, 1, 2)
self.auto_resolution = QCheckBox("自动匹配分辨率(取最大图片尺寸)")
self.auto_resolution.setChecked(True)
settings_layout.addWidget(self.auto_resolution, 2, 4, 1, 2)
main_layout.addWidget(settings_group)
# ── 中部:图片列表 ──
file_group = QGroupBox("图片列表")
file_layout = QVBoxLayout(file_group)
# 操作按钮栏
btn_layout = QHBoxLayout()
self.add_btn = QPushButton(" 添加图片文件夹")
self.add_btn.setFixedHeight(36)
self.add_btn.clicked.connect(self._select_folder)
btn_layout.addWidget(self.add_btn)
self.add_files_btn = QPushButton(" 添加图片文件")
self.add_files_btn.setFixedHeight(36)
self.add_files_btn.clicked.connect(self._select_files)
btn_layout.addWidget(self.add_files_btn)
self.clear_btn = QPushButton(" 清空列表")
self.clear_btn.setFixedHeight(36)
self.clear_btn.clicked.connect(self._clear_list)
btn_layout.addWidget(self.clear_btn)
btn_layout.addStretch()
self.count_label = QLabel("共 0 张图片")
self.count_label.setStyleSheet("color: #0078d4; font-weight: bold;")
btn_layout.addWidget(self.count_label)
file_layout.addLayout(btn_layout)
# 图片预览区域(带滚动)
self.preview_area = QScrollArea()
self.preview_area.setWidgetResizable(True)
self.preview_area.setMinimumHeight(180)
self.preview_area.setStyleSheet("""
QScrollArea { border: 1px solid #3c3c3c; border-radius: 4px; background: #1e1e1e; }
""")
self.preview_container = QWidget()
self.preview_layout = QGridLayout(self.preview_container)
self.preview_layout.setSpacing(8)
self.preview_layout.setContentsMargins(8, 8, 8, 8)
self.preview_area.setWidget(self.preview_container)
file_layout.addWidget(self.preview_area)
main_layout.addWidget(file_group, 1)
# ── 底部:输出 + 开始转换 ──
bottom_group = QGroupBox("输出文件")
bottom_layout = QHBoxLayout(bottom_group)
self.output_edit = QLineEdit()
self.output_edit.setPlaceholderText("点击右侧按钮选择输出路径...")
self.output_edit.setReadOnly(True)
bottom_layout.addWidget(self.output_edit, 1)
self.output_btn = QPushButton("选择保存位置")
self.output_btn.setFixedHeight(36)
self.output_btn.clicked.connect(self._select_output)
bottom_layout.addWidget(self.output_btn)
main_layout.addWidget(bottom_group)
# 进度条 + 开始按钮
progress_layout = QHBoxLayout()
self.progress_bar = QProgressBar()
self.progress_bar.setMinimumHeight(28)
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(True)
progress_layout.addWidget(self.progress_bar, 1)
self.start_btn = QPushButton(" 开始转换")
self.start_btn.setFixedSize(160, 42)
self.start_btn.setFont(QFont("Microsoft YaHei", 12, QFont.Bold))
self.start_btn.clicked.connect(self._start_convert)
progress_layout.addWidget(self.start_btn)
main_layout.addLayout(progress_layout)
# 状态栏
self.status_label = QLabel("就绪 | 添加图片文件夹或文件后开始")
self.status_label.setStyleSheet("color: #666666; font-size: 11px; padding: 4px;")
main_layout.addWidget(self.status_label)
def _apply_style(self):
self.setStyleSheet("""
QMainWindow { background: #1e1e1e; }
QGroupBox {
color: #cccccc;
border: 1px solid #3c3c3c;
border-radius: 6px;
margin-top: 8px;
padding-top: 16px;
font-weight: bold;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
}
QLabel { color: #cccccc; }
QSpinBox, QComboBox, QLineEdit {
background: #2b2b2b;
color: #cccccc;
border: 1px solid #3c3c3c;
border-radius: 4px;
padding: 4px 8px;
min-height: 24px;
}
QSpinBox:focus, QComboBox:focus, QLineEdit:focus {
border-color: #0078d4;
}
QComboBox::drop-down {
border: none;
width: 24px;
}
QPushButton {
background: #0078d4;
color: white;
border: none;
border-radius: 4px;
padding: 6px 16px;
}
QPushButton:hover { background: #1a8fe8; }
QPushButton:pressed { background: #005fa3; }
QProgressBar {
background: #2b2b2b;
border: 1px solid #3c3c3c;
border-radius: 4px;
text-align: center;
color: #cccccc;
}
QProgressBar::chunk {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #0078d4, stop:1 #00b4d8);
border-radius: 3px;
}
QCheckBox { color: #cccccc; spacing: 6px; }
QCheckBox::indicator {
width: 16px; height: 16px;
border: 1px solid #3c3c3c;
border-radius: 3px;
background: #2b2b2b;
}
QCheckBox::indicator:checked {
background: #0078d4;
border-color: #0078d4;
}
QScrollArea { border: none; }
QWidget#scrollAreaWidgetContents { background: transparent; }
""")
# ── 文件选择 ──────────────────────────────────────────────
def _select_folder(self):
folder = QFileDialog.getExistingDirectory(self, "选择图片文件夹")
if folder:
exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'}
files = sorted(
[Path(folder) / f for f in os.listdir(folder)
if Path(f).suffix.lower() in exts],
key=lambda p: p.name if self.sort_name_radio.isChecked() else p.stat().st_mtime
)
if files:
self.image_paths.extend(files)
self.image_paths = list(dict.fromkeys(self.image_paths)) # 去重保序
self._update_preview()
self.status_label.setText(f"已添加文件夹: {folder} | 共 {len(self.image_paths)} 张图片")
else:
QMessageBox.warning(self, "提示", "该文件夹中没有找到支持的图片文件")
def _select_files(self):
files, _ = QFileDialog.getOpenFileNames(
self, "选择图片文件", "",
"图片文件 (*.jpg *.jpeg *.png *.bmp *.tiff *.webp);;所有文件 (*)"
)
if files:
self.image_paths.extend([Path(f) for f in files])
self.image_paths = list(dict.fromkeys(self.image_paths))
self._update_preview()
self.status_label.setText(f"已添加 {len(files)} 个文件 | 共 {len(self.image_paths)} 张图片")
def _select_output(self):
# 默认保存到程序所在目录
app_dir = os.path.dirname(os.path.abspath(__file__))
default_path = os.path.join(app_dir, "output.mp4")
path, _ = QFileDialog.getSaveFileName(
self, "选择输出位置", default_path,
"MP4视频 (*.mp4);;AVI视频 (*.avi);;WebM视频 (*.webm)"
)
if path:
self.output_path = path
self.output_edit.setText(path)
def _clear_list(self):
self.image_paths.clear()
self._update_preview()
self.status_label.setText("列表已清空")
# ── 预览更新 ──────────────────────────────────────────────
def _update_preview(self):
# 清空旧预览
while self.preview_layout.count():
item = self.preview_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
count = len(self.image_paths)
self.count_label.setText(f"共 {count} 张图片")
if count == 0:
placeholder = QLabel("拖拽图片文件夹到这里,或点击上方按钮添加")
placeholder.setAlignment(Qt.AlignCenter)
placeholder.setStyleSheet("color: #555555; font-size: 13px; padding: 40px;")
self.preview_layout.addWidget(placeholder, 0, 0)
return
cols = max(1, (self.preview_area.width() - 30) // 130)
for i, img_path in enumerate(self.image_paths[:200]): # 最多显示200张
row, col = divmod(i, cols)
widget = ImagePreviewWidget(img_path, i)
self.preview_layout.addWidget(widget, row, col)
if count > 200:
more = QLabel(f"... 还有 {count - 200} 张图片")
more.setStyleSheet("color: #888888;")
row, col = divmod(200, cols)
self.preview_layout.addWidget(more, row, col)
def resizeEvent(self, event):
super().resizeEvent(event)
if self.image_paths:
self._update_preview()
# ── 开始转换 ──────────────────────────────────────────────
def _start_convert(self):
if not self.image_paths:
QMessageBox.warning(self, "提示", "请先添加图片文件")
return
if not self.output_path:
QMessageBox.warning(self, "提示", "请选择输出文件保存位置")
return
# 根据每张停留时间计算FPS
duration = self.duration_spin.value()
effective_fps = max(1, 1.0 / duration)
# 自动匹配分辨率
width = self.width_spin.value()
height = self.height_spin.value()
if self.auto_resolution.isChecked():
w, h = self._get_max_resolution()
if w > 0 and h > 0:
width, height = w, h
self.width_spin.setValue(width)
self.height_spin.setValue(height)
# 颜色映射
color_map = {
'黑色': (0, 0, 0), '白色': (255, 255, 255),
'灰色': (128, 128, 128), '蓝色': (180, 100, 20),
'绿色': (80, 160, 80)
}
bg_color = color_map.get(self.color_combo.currentText(), (0, 0, 0))
self.start_btn.setEnabled(False)
self.start_btn.setText("转换中...")
self.progress_bar.setValue(0)
self.converter = ConvertThread(
image_paths=self.image_paths,
output_path=self.output_path,
fps=effective_fps,
width=width,
height=height,
codec=self.codec_combo.currentText(),
quality=95,
loop=1,
bg_color=bg_color
)
self.converter.progress.connect(self._on_progress)
self.converter.finished_signal.connect(self._on_finished)
self.converter.error_signal.connect(self._on_error)
self.converter.start()
def _get_max_resolution(self):
max_w, max_h = 0, 0
for img_path in self.image_paths[:50]: # 只检查前50张
img = cv2.imread(str(img_path))
if img is not None:
h, w = img.shape[:2]
max_w = max(max_w, w)
max_h = max(max_h, h)
return max_w, max_h
def _on_progress(self, pct, msg):
self.progress_bar.setValue(pct)
self.status_label.setText(msg)
def _on_finished(self, path):
self.start_btn.setEnabled(True)
self.start_btn.setText(" 开始转换")
self.progress_bar.setValue(100)
self.status_label.setText(f"转换完成! 文件保存至: {path}")
QMessageBox.information(
self, "转换完成",
f"视频已成功生成!\n\n文件路径:\n{path}\n\n"
f"图片数量: {len(self.image_paths)} 张\n"
f"分辨率: {self.width_spin.value()}x{self.height_spin.value()}\n"
f"帧率: {self.fps_spin.value()} fps\n"
f"编码: {self.codec_combo.currentText()}"
)
def _on_error(self, msg):
self.start_btn.setEnabled(True)
self.start_btn.setText(" 开始转换")
self.progress_bar.setValue(0)
self.status_label.setText(f"错误: {msg}")
QMessageBox.critical(self, "转换失败", msg)
# ── 启动 ──────────────────────────────────────────────────────
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
# 深色主题全局调色板
palette = QPalette()
palette.setColor(QPalette.Window, QColor("#1e1e1e"))
palette.setColor(QPalette.WindowText, QColor("#cccccc"))
palette.setColor(QPalette.Base, QColor("#2b2b2b"))
palette.setColor(QPalette.AlternateBase, QColor("#333333"))
palette.setColor(QPalette.Text, QColor("#cccccc"))
palette.setColor(QPalette.Button, QColor("#0078d4"))
palette.setColor(QPalette.ButtonText, QColor("#ffffff"))
palette.setColor(QPalette.Highlight, QColor("#0078d4"))
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
app.setPalette(palette)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
五、代码架构解析
整个程序不到600行代码,结构清晰,分为4个核心模块:
1. ConvertThread(转换线程)
继承 QThread,在后台执行图片转视频的核心逻辑,避免阻塞UI。
关键方法:
run():主转换循环,逐帧读取图片 → 缩放适配 → 写入视频_fit_image():自适应缩放 + 居中填充,处理不同尺寸图片的统一画布问题
python
# 核心缩放逻辑
scale = min(target_w / w, target_h / h) # 等比缩放,取较小值
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
canvas = np.full((target_h, target_w, 3), self.bg_color, dtype=np.uint8)
canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized # 居中放置
2. ImagePreviewWidget(预览卡片)
自定义 QFrame 子类,每个图片生成一个120x140的缩略图卡片,支持hover高亮。
3. MainWindow(主窗口)
UI布局采用 QVBoxLayout + QGridLayout,分为4个区域:
- 输出设置区:帧率、分辨率、编码、背景色、排序方式
- 图片列表区:操作按钮 + 缩略图网格预览(带滚动条)
- 输出路径区:文件保存位置选择
- 进度区:进度条 + 开始转换按钮
4. 深色主题
通过 QPalette 全局配色 + QSS 样式表实现现代化暗色UI:
python
# 全局调色板
palette.setColor(QPalette.Window, QColor("#1e1e1e"))
palette.setColor(QPalette.Button, QColor("#0078d4"))
# ...
# QSS样式表
"QPushButton { background: #0078d4; border-radius: 4px; }"
"QPushButton:hover { background: #1a8fe8; }"
"QProgressBar::chunk { background: qlineargradient(...); }"
六、进阶用法
用命令行批量处理(无需GUI)
如果需要在服务器或CI/CD中使用,可以直接调用核心转换逻辑:
python
import cv2
import numpy as np
from pathlib import Path
def images_to_video(image_dir, output_path, fps=24, resolution=(1920, 1080)):
"""纯命令行版:图片文件夹转视频"""
exts = {'.jpg', '.jpeg', '.png', '.bmp'}
images = sorted([p for p in Path(image_dir).iterdir() if p.suffix.lower() in exts])
w, h = resolution
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
writer = cv2.VideoWriter(output_path, fourcc, fps, (w, h))
for img_path in images:
img = cv2.imread(str(img_path))
scale = min(w / img.shape[1], h / img.shape[0])
new_w, new_h = int(img.shape[1] * scale), int(img.shape[0] * scale)
resized = cv2.resize(img, (new_w, new_h))
canvas = np.full((h, w, 3), 0, dtype=np.uint8)
x_off, y_off = (w - new_w) // 2, (h - new_h) // 2
canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized
writer.write(canvas)
writer.release()
print(f"完成: {output_path}")
# 使用
images_to_video("./photos", "output.mp4", fps=1, resolution=(1080, 1920))
修改默认参数
直接改代码中的默认值即可:
python
self.fps_spin.setValue(30) # 默认帧率改为30
self.width_spin.setValue(1080) # 默认宽度改为1080
self.height_spin.setValue(1920) # 默认高度改为1920(竖屏)
self.duration_spin.setValue(5) # 默认每张停留5秒
七、常见问题
Q1:支持GIF图片吗?
A:当前版本不支持GIF。如需支持,可在导入时用Pillow将GIF拆分为多帧。
Q2:转换速度慢怎么办?
A:主要瓶颈是图片读取和缩放。建议:(1) 图片预先统一尺寸;(2) 使用SSD硬盘;(3) 减少不必要的高分辨率。
Q3:输出视频文件太大?
A:尝试:(1) 切换到H.265编码(同画质体积更小);(2) 降低分辨率到720p;(3) 减少帧率到15fps(幻灯片类内容足够)。
Q4:Windows上启动报Qt平台插件错误?
A:代码已内置修复逻辑。如仍有问题,手动设置环境变量:
bat
set QT_QPA_PLATFORM_PLUGIN_PATH=venv\Lib\site-packages\PyQt5\Qt5\plugins
python image_to_video.py
Q5:Mac/Linux能用吗?
A:可以。需要修改虚拟环境路径(代码中 venv\Lib\site-packages 改为 venv/lib/python3.x/site-packages),其余逻辑通用。
八、总结
这个工具的设计哲学是:用最少的代码解决最常见的问题。
不到600行Python代码,实现了:
- 完整的图形界面(深色主题)
- 批量图片导入与预览
- 自适应缩放与居中填充
- 多格式输出与参数调节
- 后台转换不阻塞UI
完全本地离线,免费无水印,复制即用。 如果你觉得有用,欢迎转发分享给更多需要的人。
项目文件:图片转视频工具/image_to_video.py
依赖:pip install PyQt5 opencv-python Pillow
启动:python image_to_video.py