当前位置: 首页 > news >正文

视频转图片工具

一、引言

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

四、核心逻辑流程

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

五、使用指南

运行环境

  • Windows 11
  • Python 3.9+
  • 依赖库:
    pip install opencv-python pillow

操作步骤

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

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


六、源码结构简析

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()

http://www.dtcms.com/a/570058.html

相关文章:

  • 长春网站建设方案优化网络规划设计师教程第二版电子版
  • 怎样建商业网站wordpress升级机制
  • CANN算子开发实战:从动态Shape到测试验证的深度解析
  • re一下--day8--字符串(一)
  • 网站关键词在哪里修改网站建设80hoe
  • 企业数据服务新选择:“五度易链” SaaS/API/ 本地化部署方案适配全规模需求
  • 【JUnit实战3_27】第十六章:用 JUnit 测试 Spring 应用:通过实战案例深入理解 IoC 原理
  • 网站源码模板免费网站服务器2020
  • ftp怎么连接网站空间如何建立外贸网站
  • 不同防滑设计在复杂牙拔除中的效能评估
  • 基于springboot的精准扶贫管理系统开发与设计
  • 电子学会青少年软件编程(C/C++)5级等级考试真题试卷(2025年9月)
  • linux系统rsync文件传输
  • 服务器建站用哪个系统好新闻稿件
  • 基于51单片机的宠物喂食器的设计与实现(论文+源码)
  • 建设网站入不入无形资产云南建设厅网站监理员培训
  • 佛山企业网站建设制作网页案例
  • Maven基础(二)
  • Java大厂面试真题:Spring Boot+微服务+AI智能客服三轮技术拷问实录(四)
  • 神领物流v2.0-day3-运费微服务笔记(个人记录、含练习答案、仅供参考)
  • 网站建设服务费计入会计科目做电影网站需要多大空间
  • 电机东莞网站建设营销策划公司有哪些职位
  • 《AI基础》
  • 网络推广一般怎么收费东莞网站优化制作
  • 技术支持 滕州网站建设苏州专业网站建设定制
  • 【软考架构】案例分析-管道过滤器、仓库架构风格,从数据处理方式、系统的可扩展性和处理性能三个方面对这两种架构风格进行比较与分析
  • 一种高效的端到端计算框架:用于生成心电图校准的人体心房电生理容积模型|文献速递-文献分享
  • 建一个网站需要多少钱?云梦网站建设
  • 使用 Shoelace 公式结合球面几何计算地球上任意多边形的面积
  • MCP (Model Context Protocol) 框架介绍文档