一、引言
在视频处理、AI 训练、图像分析等场景中,经常需要将视频文件逐帧导出为图片序列。市面上虽然有 FFmpeg 等命令行工具,但对于非技术人员或需要批量处理多个视频的用户来说,操作门槛较高。为此,我开发了一款轻量级、界面友好、功能完整的 视频转图片工具,支持多视频批量处理、多种抽帧策略、自定义输出格式和保存方式。(含源码及应用)
二、功能亮点
- 多视频批量处理:支持一次性添加多个视频文件,自动为每个视频创建独立子目录。
- 三种抽帧模式 :
- 全部帧(默认)
- 每隔 N 帧抽一帧
- 每隔 N 秒(自动换算为帧数)
- 多种输出格式 :
.jpg、.png、.bmp、.tiff - 双保存引擎 :
- OpenCV :速度快,适合常规格式。(OpenCV在一些目录下因权限问题会无法保存,无法保存可以尝试切换输出目录或者更换保存引擎)
- PIL(Pillow):兼容性更强,尤其适合处理特殊编码或色彩空间
- 进度可视化:实时显示处理进度、已保存/失败
- Windows 专属优化:DPI 自适应、任务栏图标、自动打开输出目录
三、技术栈解析
1. GUI 框架:tkinter + ttk
虽然 tkinter 被认为"老旧",但它无需额外依赖、跨平台(本工具限定 Windows)、轻量高效。通过 ttk(Themed Tk)组件,我们实现了现代化的界面风格:
- 使用
Listbox实现多视频文件列表管理 ttk.Combobox提供格式下拉选择ttk.Progressbar显示全局进度- 自定义
ttk.Style统一按钮、标签字体大小,提升可读性
2. 视频处理:OpenCV (cv2)
cv2.VideoCapture读取视频流- 获取关键元数据:帧率(FPS)、总帧数、分辨率
- 支持几乎所有常见视频格式(MP4、AVI、MOV、MKV 等)
3. 图像保存:双引擎策略
python
def save_image(self, frame, path):
if self.save_method.get() == "pil":
# 使用 PIL 保存(BGR → RGB 转换)
else:
# 使用 OpenCV 保存
# 若失败,自动尝试另一种方式
这种设计极大提升了工具的鲁棒性,避免因编码器缺失导致保存失败。
4. 多线程处理
为避免 GUI 卡死,所有耗时操作(计算总帧数、视频转换)均在后台线程中执行:
python
threading.Thread(target=self._convert_all_videos, args=(total_frames,), daemon=True).start()
同时通过 root.update_idletasks() 安全地更新界面状态。
5. Windows 专属优化
- 启动时检测系统,非 Windows 直接退出
- 调用
ctypes.windll.shcore.SetProcessDpiAwareness(1)实现高 DPI 缩放适配 - 转换完成后调用
os.startfile()自动打开输出文件夹
四、核心逻辑流程
- 用户选择多个视频文件 → 存入
self.video_paths - 选择输出目录 (默认为第一个视频所在目录下的
video_frames) - 设置抽帧参数(全部 / 每 N 帧 / 每 N 秒)
- 点击"开始转换" :
- 后台线程计算所有视频的总帧数(用于进度条)
- 遍历每个视频:
- 创建子目录(以视频名命名)
- 按设定间隔读取帧
- 调用
save_image保存图像 - 实时更新进度与状态
- 全部完成后 :
- 弹出结果提示框
- 自动打开输出目录(仅当有成功保存的图片)
五、使用指南
运行环境
-
Windows 11
-
Python 3.9+
-
依赖库:
bashpip install opencv-python pillow
操作步骤
- 添加视频:点击"添加视频",支持多选
- 设置输出目录:可手动选择,也可使用默认路径
- 选择抽帧方式 :
- 全部抽取:导出每一帧(适合短片或关键帧分析)
- 每隔 N 帧:例如每 10 帧保存一张
- 每隔 N 秒:例如每 1 秒保存一张(自动根据 FPS 计算)
- 选择输出格式与保存方式 (如 PIL)
- 点击"开始转换",等待完成即可
提示:处理大视频时请耐心等待,进度条会实时更新。
六、源码结构简析
python
class VideoToFramesApp:
def __init__(self, root): # 初始化窗口与变量
def create_widgets(self): # 构建 GUI 界面
def add_videos/remove_selected... # 文件列表管理
def start_conversion(): # 启动转换流程
def calculate_total_frames(): # 预计算总帧数
def _convert_all_videos(): # 核心转换逻辑
def save_image(): # 双引擎图像保存
整个程序采用面向对象设计,逻辑清晰,易于扩展(例如未来可加入压缩选项、分辨率调整等)。
八、结语
本文通过一个结构清晰、功能完整的 Python 桌面应用,展示了如何在 Windows 平台上利用 OpenCV 与 PIL 实现多视频批量帧提取。借助多线程处理与双保存引擎机制,工具在保证操作流畅性的同时,兼顾了兼容性与稳定性。整个实现兼顾易用性与实用性,不仅满足日常视频处理需求,也为进一步开发图像采集类应用提供了可靠基础。代码逻辑清晰、注释详尽,非常适合初学者学习 GUI 编程与多媒体处理,也便于集成到更复杂的项目中。
九、源码及可执行软件
软件
『来自123云盘用户19840272070的分享』视频转图片工具 链接:https://www.123865.com/s/NDjrVv-CKBq?pwd=iILD# 提取码:iILD
源码
python
import ctypes
import cv2
import os
import platform
import sys
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from PIL import Image # 使用PIL库作为备选保存方式
class VideoToFramesApp:
def __init__(self, root):
self.root = root
self.root.title("视频转图片工具-V1.1-sunsunyu03")
self.root.geometry("900x800") # 增大窗口尺寸
self.root.minsize(800, 550) # 增大最小尺寸
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
# Windows 限制
if platform.system() != "Windows":
messagebox.showerror("错误", "该程序仅支持 Windows 平台运行")
root.destroy()
return
self.set_dpi_scaling()
# 增大字体和样式参数
self.large_font = ("Microsoft YaHei", 12)
self.title_font = ("Microsoft YaHei", 10, "bold")
self.button_font = ("Microsoft YaHei", 11)
# 控制变量
self.video_paths = [] # 改为存储多个视频路径
self.output_dir = tk.StringVar()
self.interval_type = tk.StringVar(value="all")
self.frame_interval = tk.IntVar(value=10)
self.time_interval = tk.DoubleVar(value=1.0)
self.output_format = tk.StringVar(value=".jpg")
self.conversion_active = False
self.save_method = tk.StringVar(value="pil") # 存储方式选择
self.total_progress = 0 # 总进度
self.current_progress = 0 # 当前视频进度
self.create_widgets()
def set_dpi_scaling(self):
if platform.system() == "Windows":
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
scale_factor = ctypes.windll.shcore.GetScaleFactorForDevice(0) / 100
self.root.tk.call('tk', 'scaling', scale_factor)
except:
pass
def create_widgets(self):
main_frame = ttk.Frame(self.root, padding=20) # 增加内边距
main_frame.grid(row=0, column=0, sticky="nsew")
main_frame.columnconfigure(1, weight=1)
# 设置更大的行高
for i in range(10): # 增加一行
main_frame.rowconfigure(i, minsize=40)
# 视频文件选择(多选)
ttk.Label(main_frame, text="视频文件(可多选):", font=self.large_font).grid(row=0, column=0, sticky="w", pady=5)
# 创建列表框和滚动条
list_frame = ttk.Frame(main_frame)
list_frame.grid(row=0, column=1, columnspan=2, sticky="nsew", padx=10, pady=5)
list_frame.columnconfigure(0, weight=1)
list_frame.rowconfigure(0, weight=1)
self.video_listbox = tk.Listbox(list_frame, height=5, font=self.large_font, selectmode=tk.EXTENDED)
self.video_listbox.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.video_listbox.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.video_listbox.config(yscrollcommand=scrollbar.set)
# 按钮框架
btn_frame = ttk.Frame(main_frame)
btn_frame.grid(row=1, column=0, columnspan=3, sticky="ew", pady=5)
ttk.Button(btn_frame, text="添加视频", command=self.add_videos, style="Large.TButton").pack(side="left", padx=5)
ttk.Button(btn_frame, text="移除选中", command=self.remove_selected, style="Large.TButton").pack(side="left",
padx=5)
ttk.Button(btn_frame, text="清空列表", command=self.clear_list, style="Large.TButton").pack(side="left", padx=5)
# 输出目录选择
ttk.Label(main_frame, text="输出目录:", font=self.large_font).grid(row=2, column=0, sticky="w", pady=5)
output_entry = ttk.Entry(main_frame, textvariable=self.output_dir, font=self.large_font)
output_entry.grid(row=2, column=1, sticky="ew", padx=10, pady=5)
ttk.Button(main_frame, text="选择目录", command=self.select_output_dir, style="Large.TButton").grid(row=2,
column=2,
padx=10)
# 抽帧选项
ttk.Label(main_frame, text="抽帧方式:", font=self.large_font).grid(row=3, column=0, sticky="w", pady=10)
frame = ttk.LabelFrame(main_frame, text="抽帧选项", padding=15) # 增加内边距
frame.grid(row=4, column=0, columnspan=3, sticky="ew", pady=10, padx=10)
# 增加单选按钮的字体大小
ttk.Radiobutton(frame, text="全部抽取", variable=self.interval_type, value="all",
command=self.update_controls, style="Large.TRadiobutton").grid(row=0, column=0, sticky="w",
pady=5)
frame_opt = ttk.Frame(frame)
frame_opt.grid(row=1, column=0, sticky="w", pady=5)
ttk.Radiobutton(frame_opt, text="每隔", variable=self.interval_type, value="frame",
command=self.update_controls, style="Large.TRadiobutton").grid(row=0, column=0)
self.frame_entry = ttk.Entry(frame_opt, textvariable=self.frame_interval, width=8, font=self.large_font)
self.frame_entry.grid(row=0, column=1, padx=8)
ttk.Label(frame_opt, text="帧", font=self.large_font).grid(row=0, column=2)
time_opt = ttk.Frame(frame)
time_opt.grid(row=2, column=0, sticky="w", pady=5)
ttk.Radiobutton(time_opt, text="每隔", variable=self.interval_type, value="time",
command=self.update_controls, style="Large.TRadiobutton").grid(row=0, column=0)
self.time_entry = ttk.Entry(time_opt, textvariable=self.time_interval, width=8, font=self.large_font)
self.time_entry.grid(row=0, column=1, padx=8)
ttk.Label(time_opt, text="秒", font=self.large_font).grid(row=0, column=2)
# 输出格式
format_frame = ttk.Frame(main_frame)
format_frame.grid(row=5, column=0, columnspan=3, sticky="w", pady=10)
ttk.Label(format_frame, text="输出格式:", font=self.large_font).grid(row=0, column=0, padx=5)
self.format_menu = ttk.Combobox(format_frame, textvariable=self.output_format,
values=[".jpg", ".png", ".bmp", ".tiff"], width=8,
font=self.large_font, state="readonly")
self.format_menu.grid(row=0, column=1, padx=10)
# 存储方式选择
storage_frame = ttk.Frame(main_frame)
storage_frame.grid(row=6, column=0, columnspan=3, sticky="w", pady=10)
ttk.Label(storage_frame, text="存储方式:", font=self.large_font).grid(row=0, column=0, padx=5)
# 添加存储方式单选按钮
storage_method_frame = ttk.Frame(storage_frame)
storage_method_frame.grid(row=0, column=1, sticky="w")
ttk.Radiobutton(storage_method_frame, text="PIL (兼容性更好)", variable=self.save_method,
value="pil", style="Large.TRadiobutton").grid(row=0, column=0, padx=10)
ttk.Radiobutton(storage_method_frame, text="OpenCV (推荐)", variable=self.save_method,
value="opencv", style="Large.TRadiobutton").grid(row=0, column=1, padx=10)
# 控制按钮
ctrl_frame = ttk.Frame(main_frame)
ctrl_frame.grid(row=7, column=0, columnspan=3, pady=20) # 行号增加
self.convert_btn = ttk.Button(ctrl_frame, text="开始转换", command=self.start_conversion,
style="Action.TButton", width=12)
self.convert_btn.pack(side="left", padx=15)
ttk.Button(ctrl_frame, text="退出", command=self.root.quit,
style="Large.TButton", width=12).pack(side="left", padx=15)
# 进度条 - 增加高度
self.progress = ttk.Progressbar(main_frame, orient="horizontal", mode="determinate", length=500)
self.progress.grid(row=8, column=0, columnspan=3, sticky="ew", pady=15, padx=10) # 行号增加
# 状态标签 - 增大字体
self.status = tk.StringVar(value="就绪")
status_label = ttk.Label(main_frame, textvariable=self.status, font=self.large_font)
status_label.grid(row=9, column=0, columnspan=3, pady=10) # 行号增加
# 创建自定义样式
self.create_styles()
self.update_controls()
def create_styles(self):
style = ttk.Style()
style.configure("Large.TButton", font=self.button_font, padding=6)
style.configure("Action.TButton", font=("Microsoft YaHei", 12, "bold"), padding=8)
style.configure("Large.TRadiobutton", font=self.large_font)
style.configure("Large.TLabel", font=self.large_font)
style.configure("TLabelframe", font=self.title_font)
style.configure("TLabelframe.Label", font=self.title_font)
style.configure("TCombobox", font=self.large_font)
style.configure("TListbox", font=self.large_font)
def update_controls(self):
t = self.interval_type.get()
self.frame_entry.config(state="normal" if t == "frame" else "disabled")
self.time_entry.config(state="normal" if t == "time" else "disabled")
def add_videos(self):
files = filedialog.askopenfilenames(
title="选择视频文件",
filetypes=[("视频文件", "*.mp4 *.avi *.mov *.mkv *.flv *.wmv *.mpg *.mpeg")]
)
if files:
for file in files:
if file not in self.video_paths:
self.video_paths.append(file)
self.video_listbox.insert(tk.END, file)
# 自动设置输出目录(以第一个视频的目录为基础)
if not self.output_dir.get() and self.video_paths:
first_video = self.video_paths[0]
base_name = os.path.splitext(os.path.basename(first_video))[0]
self.output_dir.set(os.path.join(os.path.dirname(first_video), "video_frames"))
def remove_selected(self):
selected_indices = self.video_listbox.curselection()
for i in selected_indices[::-1]:
self.video_listbox.delete(i)
del self.video_paths[i]
def clear_list(self):
self.video_listbox.delete(0, tk.END)
self.video_paths = []
def select_output_dir(self):
if d := filedialog.askdirectory(title="选择输出目录"):
self.output_dir.set(d)
def start_conversion(self):
if self.conversion_active:
return
if not self.video_paths:
messagebox.showwarning("警告", "请添加至少一个视频文件")
return
if not self.output_dir.get():
messagebox.showwarning("警告", "请选择输出目录")
return
self.convert_btn.config(state="disabled")
self.progress["value"] = 0
self.conversion_active = True
self.total_progress = 0
self.current_progress = 0
# 计算总帧数用于进度条
threading.Thread(target=self.calculate_total_frames, daemon=True).start()
def calculate_total_frames(self):
"""计算所有视频的总帧数"""
total_frames = 0
self.status.set("正在计算视频总帧数...")
self.root.update_idletasks()
for i, video_path in enumerate(self.video_paths):
try:
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
continue
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
total_frames += frame_count
cap.release()
self.status.set(f"计算中... ({i + 1}/{len(self.video_paths)})")
self.root.update_idletasks()
except:
pass
self.status.set(f"共 {len(self.video_paths)} 个视频,总帧数: {total_frames}")
self.root.update_idletasks()
# 开始转换
threading.Thread(target=self._convert_all_videos, args=(total_frames,), daemon=True).start()
def save_image(self, frame, path):
"""使用选定的方法保存图像"""
try:
ext = os.path.splitext(path)[1].lower()
if self.save_method.get() == "pil":
# 使用PIL保存图像(兼容性更好)
# 将BGR转换为RGB
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(frame_rgb)
# 根据格式设置保存选项
if ext == ".jpg":
pil_image.save(path, quality=95)
elif ext == ".png":
pil_image.save(path, compress_level=3)
else:
pil_image.save(path)
return True
else:
# 使用OpenCV保存图像
# 对于JPEG,设置质量参数
if ext == ".jpg":
return cv2.imwrite(path, frame, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
# 对于PNG,设置压缩级别
elif ext == ".png":
return cv2.imwrite(path, frame, [int(cv2.IMWRITE_PNG_COMPRESSION), 3])
else:
return cv2.imwrite(path, frame)
except Exception as e:
# 如果首选方法失败,尝试另一种方法
try:
if self.save_method.get() == "pil":
# 尝试使用OpenCV
return cv2.imwrite(path, frame)
else:
# 尝试使用PIL
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(frame_rgb)
pil_image.save(path)
return True
except:
self.status.set(f"保存失败: {str(e)}")
return False
def _convert_all_videos(self, total_frames):
total_saved = 0
total_failed = 0
processed_frames = 0
for video_idx, video_path in enumerate(self.video_paths):
if not self.conversion_active:
break
# 为每个视频创建单独的子目录
video_name = os.path.splitext(os.path.basename(video_path))[0]
output_subdir = os.path.join(self.output_dir.get(), video_name)
os.makedirs(output_subdir, exist_ok=True)
self.status.set(f"处理视频 {video_idx + 1}/{len(self.video_paths)}: {os.path.basename(video_path)}")
self.root.update_idletasks()
cap = None
try:
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
self.status.set(f"无法打开视频: {os.path.basename(video_path)}")
continue
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
if self.interval_type.get() == "frame":
interval = max(1, self.frame_interval.get())
elif self.interval_type.get() == "time":
interval = max(1, int(fps * self.time_interval.get()))
else:
interval = 1
count, saved, failed = 0, 0, 0
while True:
ret, frame = cap.read()
if not ret:
break
# 更新进度
processed_frames += 1
progress_value = (processed_frames / total_frames) * 100
self.progress["value"] = progress_value
# 每隔一定帧数保存图像
if count % interval == 0:
img_path = os.path.join(output_subdir, f"{saved + 1}{self.output_format.get()}")
if self.save_image(frame, img_path):
saved += 1
else:
failed += 1
# 每处理10帧更新一次状态
if count % 10 == 0:
self.status.set(
f"视频 {video_idx + 1}/{len(self.video_paths)}: 已处理 {count}/{frame_count} 帧,保存 {saved} 张,失败 {failed} 张")
self.root.update_idletasks()
count += 1
total_saved += saved
total_failed += failed
self.status.set(f"视频完成: 保存 {saved} 张,失败 {failed} 张")
self.root.update_idletasks()
except Exception as e:
self.status.set(f"处理视频时出错: {str(e)}")
finally:
if cap:
cap.release()
if self.conversion_active:
self.progress["value"] = 100
self.status.set(f"全部完成:共保存 {total_saved} 张图片,失败 {total_failed} 张")
self.root.update_idletasks()
result_msg = f"已处理 {len(self.video_paths)} 个视频\n"
result_msg += f"保存 {total_saved} 张图片到:\n{self.output_dir.get()}"
if total_failed > 0:
result_msg += f"\n\n注意:有 {total_failed} 张图片保存失败"
messagebox.showinfo("完成", result_msg)
# 仅在有成功保存的图片时才打开文件夹
if total_saved > 0:
os.startfile(os.path.abspath(self.output_dir.get()))
self.conversion_active = False
self.convert_btn.config(state="normal")
self.progress["value"] = 0
if __name__ == "__main__":
if sys.platform == "win32":
ctypes.windll.kernel32.SetConsoleOutputCP(65001)
ctypes.windll.kernel32.SetConsoleCP(65001)
root = tk.Tk()
app = VideoToFramesApp(root)
root.mainloop()