视频转图片工具
一、引言
在视频处理、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. 图像保存:双引擎策略
def save_image(self, frame, path):if self.save_method.get() == "pil":# 使用 PIL 保存(BGR → RGB 转换)else:# 使用 OpenCV 保存# 若失败,自动尝试另一种方式
这种设计极大提升了工具的鲁棒性,避免因编码器缺失导致保存失败。
4. 多线程处理
为避免 GUI 卡死,所有耗时操作(计算总帧数、视频转换)均在后台线程中执行:
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+
- 依赖库:
pip install opencv-python pillow
操作步骤
- 添加视频:点击“添加视频”,支持多选
- 设置输出目录:可手动选择,也可使用默认路径
- 选择抽帧方式:
- 全部抽取:导出每一帧(适合短片或关键帧分析)
- 每隔 N 帧:例如每 10 帧保存一张
- 每隔 N 秒:例如每 1 秒保存一张(自动根据 FPS 计算)
- 选择输出格式与保存方式(如 PIL)
- 点击“开始转换”,等待完成即可
提示:处理大视频时请耐心等待,进度条会实时更新。
六、源码结构简析
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
源码
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 = rootself.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()returnself.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 = Falseself.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) / 100self.root.tk.call('tk', 'scaling', scale_factor)except:passdef 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:returnif not self.video_paths:messagebox.showwarning("警告", "请添加至少一个视频文件")returnif not self.output_dir.get():messagebox.showwarning("警告", "请选择输出目录")returnself.convert_btn.config(state="disabled")self.progress["value"] = 0self.conversion_active = Trueself.total_progress = 0self.current_progress = 0# 计算总帧数用于进度条threading.Thread(target=self.calculate_total_frames, daemon=True).start()def calculate_total_frames(self):"""计算所有视频的总帧数"""total_frames = 0self.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():continueframe_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))total_frames += frame_countcap.release()self.status.set(f"计算中... ({i + 1}/{len(self.video_paths)})")self.root.update_idletasks()except:passself.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转换为RGBframe_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 Trueelse:# 使用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":# 尝试使用OpenCVreturn cv2.imwrite(path, frame)else:# 尝试使用PILframe_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)pil_image = Image.fromarray(frame_rgb)pil_image.save(path)return Trueexcept:self.status.set(f"保存失败: {str(e)}")return Falsedef _convert_all_videos(self, total_frames):total_saved = 0total_failed = 0processed_frames = 0for 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 = Nonetry:cap = cv2.VideoCapture(video_path)if not cap.isOpened():self.status.set(f"无法打开视频: {os.path.basename(video_path)}")continuefps = 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 = 1count, saved, failed = 0, 0, 0while True:ret, frame = cap.read()if not ret:break# 更新进度processed_frames += 1progress_value = (processed_frames / total_frames) * 100self.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 += 1else: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 += 1total_saved += savedtotal_failed += failedself.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"] = 100self.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 = Falseself.convert_btn.config(state="normal")self.progress["value"] = 0if __name__ == "__main__":if sys.platform == "win32":ctypes.windll.kernel32.SetConsoleOutputCP(65001)ctypes.windll.kernel32.SetConsoleCP(65001)root = tk.Tk()app = VideoToFramesApp(root)root.mainloop()
