视频转图片工具

一、引言

在视频处理、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() 自动打开输出文件夹

四、核心逻辑流程

  1. 用户选择多个视频文件 → 存入 self.video_paths
  2. 选择输出目录 (默认为第一个视频所在目录下的 video_frames
  3. 设置抽帧参数(全部 / 每 N 帧 / 每 N 秒)
  4. 点击"开始转换"
    • 后台线程计算所有视频的总帧数(用于进度条)
    • 遍历每个视频:
      • 创建子目录(以视频名命名)
      • 按设定间隔读取帧
      • 调用 save_image 保存图像
      • 实时更新进度与状态
  5. 全部完成后
    • 弹出结果提示框
    • 自动打开输出目录(仅当有成功保存的图片)

五、使用指南

运行环境

  • Windows 11

  • Python 3.9+

  • 依赖库:

    bash 复制代码
    pip install opencv-python pillow

操作步骤

  1. 添加视频:点击"添加视频",支持多选
  2. 设置输出目录:可手动选择,也可使用默认路径
  3. 选择抽帧方式
    • 全部抽取:导出每一帧(适合短片或关键帧分析)
    • 每隔 N 帧:例如每 10 帧保存一张
    • 每隔 N 秒:例如每 1 秒保存一张(自动根据 FPS 计算)
  4. 选择输出格式与保存方式 (如 PIL
  5. 点击"开始转换",等待完成即可

提示:处理大视频时请耐心等待,进度条会实时更新。


六、源码结构简析

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()
相关推荐
六件套是我2 小时前
视频进度代码,延时队列方案
音视频
软件开发技术深度爱好者2 小时前
Python类中方法种类介绍
开发语言·python
用户8356290780513 小时前
使用Python合并Word文档:实现高效自动化办公
后端·python
闭着眼睛学算法3 小时前
【双机位A卷】华为OD笔试之【排序】双机位A-银行插队【Py/Java/C++/C/JS/Go六种语言】【欧弟算法】全网注释最详细分类最全的华子OD真题题解
java·c语言·javascript·c++·python·算法·华为od
Pocker_Spades_A3 小时前
Python快速入门专业版(五十四):爬虫基石:HTTP协议全解析(从请求到响应,附Socket模拟请求)
爬虫·python·http
王道长服务器 | 亚马逊云4 小时前
AWS + WordPress:中小型外贸独立站的理想组合
服务器·网络·云计算·音视频·aws
DoubleKK4 小时前
Python 中的 json_repair 使用教程:轻松修复大模型返回的非法 JSON
python
萧鼎5 小时前
深入掌握 OpenCV-Python:从图像处理到智能视觉
图像处理·python·opencv
海琴烟Sunshine5 小时前
leetcode 190. 颠倒二进制位 python
python·算法·leetcode