编辑
2026-04-27
Python
00

目录

🎬 你真的需要一个自己写的录屏工具吗?
🧱 技术选型:为什么是这套组合?
🔧 环境准备
🏗️ 整体架构设计
💻 完整代码实现
📁 项目结构
🎯 第一步:录制核心模块 recorder.py
🎨 第二步:界面模块 ui.py
🚀 第三步:入口文件 main.py
⚡ 性能优化:从"能用"到"好用"
帧率为什么对不上?
内存别让它无限涨
🕳️ 踩坑预警
🧩 扩展方向
📌 三句话总结

🎬 你真的需要一个自己写的录屏工具吗?

先说结论:需要,而且非常值得。

市面上的录屏软件要么臃肿、要么收费、要么在某些企业内网环境下根本装不上。作为 Python 开发者,我们手里有 Tkinter、有 OpenCV、有 threading——完全可以在一个下午的时间里,从零撸出一个轻量、可控、可二次开发的屏幕录制工具。

我在给内部团队做技术分享录制时,就踩过这个坑:OBS 太重,ShareX 在某台老机器上崩溃,最后索性自己写。写完之后发现,不过 300 行代码,性能却出乎意料地稳。帧率稳在 25fps,CPU 占用不超过 15%。这篇文章,就把这套思路完整拆给你看。


🧱 技术选型:为什么是这套组合?

核心依赖只有三个:

  • Tkinter:Python 内置 GUI 库,零安装成本,跨平台
  • Pillow(PIL):截图能力,ImageGrab.grab() 在 Windows 下性能相当可观
  • OpenCV(cv2):视频编码写入,VideoWriter 支持多种编解码器

有人会问,为什么不用 pyautogui 截图?原因很简单——pyautogui.screenshot() 底层也是调 PIL,但多了一层封装,速度反而更慢。直接用 ImageGrab 是最短路径。

另外,帧率控制这块,咱们用 threading.Event 配合时间戳对齐,而不是简单粗暴地 time.sleep()。这个细节差别很大,后面会详细讲。


🔧 环境准备

bash
pip install pillow opencv-python numpy

Tkinter 是 Python 标准库的一部分,Windows 下安装 Python 时默认勾选,一般不需要额外安装。如果你用的是精简版 Python 环境,执行 import tkinter 报错的话,重装一遍 Python 并勾选 tcl/tk 组件即可。


🏗️ 整体架构设计

在动手写代码之前,先把架构想清楚。这个录制器分三层:

┌─────────────────────────────────┐ │ Tkinter GUI 层 │ ← 用户交互、状态展示 ├─────────────────────────────────┤ │ 录制控制层 │ ← 线程调度、帧率控制 ├─────────────────────────────────┤ │ 底层采集 & 编码层 │ ← 截图、帧写入 └─────────────────────────────────┘

GUI 层和录制逻辑必须跑在不同线程上。这不是可选项,是必须的——录制是 CPU 密集型操作,如果塞在主线程里,界面会直接卡死,按钮点不动,体验极差。


💻 完整代码实现

📁 项目结构

screen_recorder/ ├── main.py # 入口文件 ├── recorder.py # 录制核心逻辑 └── ui.py # Tkinter 界面

🎯 第一步:录制核心模块 recorder.py

这是整个项目最关键的部分。帧率控制的精髓在这里。

python
import cv2 import numpy as np import threading import time from PIL import ImageGrab class ScreenRecorder: def __init__(self, output_path, fps=25, region=None): """ output_path: 输出文件路径,如 'output.mp4' fps: 目标帧率 region: 录制区域 (x1, y1, x2, y2),None 表示全屏 """ self.output_path = output_path self.fps = fps self.region = region self.is_recording = False self._stop_event = threading.Event() self._thread = None self.frame_count = 0 self.actual_fps = 0.0 def _get_screen_size(self): """获取录制区域尺寸""" if self.region: x1, y1, x2, y2 = self.region return (x2 - x1, y2 - y1) # 全屏尺寸 import tkinter as tk root = tk.Tk() w = root.winfo_screenwidth() h = root.winfo_screenheight() root.destroy() return (w, h) def _capture_frame(self): """截取一帧,转换为 OpenCV 格式""" img = ImageGrab.grab(bbox=self.region) # PIL Image (RGB) → numpy array → BGR (OpenCV 格式) frame = np.array(img) frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) return frame def _record_loop(self): """核心录制循环,精确帧率控制""" width, height = self._get_screen_size() # 使用 mp4v 编解码器,兼容性最好 fourcc = cv2.VideoWriter_fourcc(*'mp4v') writer = cv2.VideoWriter( self.output_path, fourcc, self.fps, (width, height) ) if not writer.isOpened(): raise RuntimeError(f"无法创建视频文件: {self.output_path}") frame_interval = 1.0 / self.fps start_time = time.perf_counter() self.frame_count = 0 try: while not self._stop_event.is_set(): frame_start = time.perf_counter() # 截图 & 写帧 frame = self._capture_frame() writer.write(frame) self.frame_count += 1 # 精确帧间隔控制(关键!) elapsed = time.perf_counter() - frame_start sleep_time = frame_interval - elapsed if sleep_time > 0: time.sleep(sleep_time) # 实时计算实际帧率 total_elapsed = time.perf_counter() - start_time if total_elapsed > 0: self.actual_fps = self.frame_count / total_elapsed finally: writer.release() def start(self): """启动录制(非阻塞)""" if self.is_recording: return self._stop_event.clear() self.is_recording = True self._thread = threading.Thread( target=self._record_loop, daemon=True ) self._thread.start() def stop(self): """停止录制,等待线程结束""" if not self.is_recording: return self._stop_event.set() self._thread.join(timeout=5) self.is_recording = False def get_stats(self): """返回当前录制统计信息""" return { "frame_count": self.frame_count, "actual_fps": round(self.actual_fps, 1) }

这里有个细节值得展开说——time.perf_counter() 而不是 time.time()。前者是高精度计时器,在 Windows 下精度能到微秒级;后者在某些系统上精度只有 10-15ms,对帧率控制来说误差太大了。

🎨 第二步:界面模块 ui.py

界面不求华丽,但信息要到位:录制状态、实际帧率、已录帧数,一眼就能看清楚。

python
import tkinter as tk from tkinter import ttk, filedialog, messagebox import threading from recorder import ScreenRecorder class RecorderUI: def __init__(self): self.root = tk.Tk() self.root.title("屏幕录制器") self.root.geometry("420x320") self.root.resizable(False, False) self.recorder = None self._stats_job = None # 用于取消定时任务 self._build_ui() def _build_ui(self): """构建界面布局""" pad = {"padx": 12, "pady": 6} # ── 输出文件选择 ── file_frame = ttk.LabelFrame(self.root, text="输出文件", padding=8) file_frame.pack(fill="x", **pad) self.path_var = tk.StringVar(value="output.mp4") ttk.Entry(file_frame, textvariable=self.path_var, width=30).pack( side="left", padx=(0, 6) ) ttk.Button( file_frame, text="浏览", command=self._choose_file ).pack(side="left") # ── 录制参数 ── param_frame = ttk.LabelFrame(self.root, text="录制参数", padding=8) param_frame.pack(fill="x", **pad) ttk.Label(param_frame, text="目标帧率 (FPS):").grid( row=0, column=0, sticky="w" ) self.fps_var = tk.IntVar(value=25) ttk.Spinbox( param_frame, from_=5, to=60, textvariable=self.fps_var, width=8 ).grid(row=0, column=1, padx=8) ttk.Label(param_frame, text="录制区域:").grid( row=1, column=0, sticky="w", pady=(6, 0) ) self.region_var = tk.StringVar(value="全屏") region_combo = ttk.Combobox( param_frame, textvariable=self.region_var, values=["全屏", "自定义区域"], state="readonly", width=12 ) region_combo.grid(row=1, column=1, padx=8, pady=(6, 0)) # ── 状态信息 ── status_frame = ttk.LabelFrame(self.root, text="录制状态", padding=8) status_frame.pack(fill="x", **pad) self.status_label = ttk.Label( status_frame, text="就绪", foreground="gray" ) self.status_label.pack(anchor="w") self.stats_label = ttk.Label( status_frame, text="帧率: -- | 已录帧数: --" ) self.stats_label.pack(anchor="w") # ── 控制按钮 ── btn_frame = ttk.Frame(self.root) btn_frame.pack(pady=10) self.start_btn = ttk.Button( btn_frame, text="▶ 开始录制", command=self._start_recording, width=14 ) self.start_btn.pack(side="left", padx=6) self.stop_btn = ttk.Button( btn_frame, text="■ 停止录制", command=self._stop_recording, width=14, state="disabled" ) self.stop_btn.pack(side="left", padx=6) def _choose_file(self): path = filedialog.asksaveasfilename( defaultextension=".mp4", filetypes=[("MP4 视频", "*.mp4"), ("所有文件", "*.*")] ) if path: self.path_var.set(path) def _start_recording(self): output = self.path_var.get().strip() if not output: messagebox.showwarning("提示", "请先选择输出文件路径") return fps = self.fps_var.get() region = None # 暂时只支持全屏,自定义区域可扩展 self.recorder = ScreenRecorder(output, fps=fps, region=region) try: self.recorder.start() except Exception as e: messagebox.showerror("错误", f"录制启动失败:{e}") return self.start_btn.config(state="disabled") self.stop_btn.config(state="normal") self.status_label.config(text="录制中...", foreground="red") # 每秒刷新一次状态 self._update_stats() def _stop_recording(self): if self.recorder: # 停止定时刷新 if self._stats_job: self.root.after_cancel(self._stats_job) self._stats_job = None # 停止录制(可能耗时,放子线程避免界面卡顿) def _do_stop(): self.recorder.stop() stats = self.recorder.get_stats() self.root.after(0, lambda: self._on_stopped(stats)) threading.Thread(target=_do_stop, daemon=True).start() def _on_stopped(self, stats): """录制停止后的 UI 更新(必须在主线程执行)""" self.start_btn.config(state="normal") self.stop_btn.config(state="disabled") self.status_label.config(text="录制完成", foreground="green") self.stats_label.config( text=f"帧率: {stats['actual_fps']} | 总帧数: {stats['frame_count']}" ) messagebox.showinfo( "完成", f"录制完成!\n共 {stats['frame_count']} 帧\n" f"实际帧率: {stats['actual_fps']} fps\n" f"文件: {self.path_var.get()}" ) def _update_stats(self): """定时刷新录制状态(在主线程调用)""" if self.recorder and self.recorder.is_recording: stats = self.recorder.get_stats() self.stats_label.config( text=f"帧率: {stats['actual_fps']} fps | 已录帧数: {stats['frame_count']}" ) # 1000ms 后再次调用自身 self._stats_job = self.root.after(1000, self._update_stats) def run(self): self.root.mainloop()

🚀 第三步:入口文件 main.py

python
from ui import RecorderUI if __name__ == "__main__": app = RecorderUI() app.run()

image.png

就这三行。干净。


⚡ 性能优化:从"能用"到"好用"

帧率为什么对不上?

这是最常见的问题。你设置了 25fps,实际跑出来可能只有 18fps。原因通常有两个:

截图本身耗时。 ImageGrab.grab() 在全屏 1080p 下,单次调用耗时大约 20~40ms,本身就快接近一帧的时间预算了(25fps = 40ms/帧)。解决方案是降低录制分辨率,或者缩小录制区域——很多时候你根本不需要录整个屏幕。

编码写入耗时。 cv2.VideoWriter.write() 是同步操作,压缩编码会占用一定时间。进阶做法是引入双缓冲队列:截图线程只负责把帧放进队列,另一个编码线程从队列里取帧写入文件,两者互不阻塞。

python
# 双缓冲队列示意(进阶版) import queue frame_queue = queue.Queue(maxsize=30) # 最多缓冲 30 帧 def capture_thread(): while not stop_event.is_set(): frame = _capture_frame() try: frame_queue.put_nowait(frame) except queue.Full: pass # 队列满了就丢帧,优先保证实时性 def encode_thread(): while not stop_event.is_set() or not frame_queue.empty(): try: frame = frame_queue.get(timeout=0.1) writer.write(frame) except queue.Empty: continue

这个改动能让截图和编码并行运行,帧率稳定性明显提升。

内存别让它无限涨

队列的 maxsize=30 这个参数很重要。如果不限制,在编码速度跟不上截图速度时,队列会无限积压帧数据,内存蹭蹭往上涨。30 帧大约是 1 秒的缓冲量,超出就主动丢帧——对录屏来说,偶尔丢一两帧远比内存爆掉要好。


🕳️ 踩坑预警

坑一:视频文件打不开。mp4v 编解码器生成的 .mp4 文件,在某些播放器下可能无法正常播放。换成 avc1(H.264)通常能解决,但需要系统安装了对应的编解码器。最稳妥的方案是生成后用 FFmpeg 转一遍:ffmpeg -i output.mp4 -vcodec libx264 final.mp4

坑二:多显示器截图区域错乱。 ImageGrab.grab() 在多显示器环境下,坐标系是以主显示器左上角为原点的。如果你的副屏在主屏左边,坐标会出现负值,需要特殊处理。

坑三:after() 不能在子线程调用。 Tkinter 的 GUI 操作必须在主线程执行。子线程想更新界面,只能通过 root.after(0, callback) 把操作调度回主线程,或者用线程安全的队列传递消息。代码里 _on_stopped 的写法就是标准做法,别嫌麻烦。

坑四:程序退出时线程没清理干净。 录制线程设置了 daemon=True,这意味着主线程退出时它会被强制终止。但 VideoWriter 可能来不及 release(),导致视频文件损坏。建议在窗口关闭事件里显式调用 stop()

python
self.root.protocol("WM_DELETE_WINDOW", self._on_close) def _on_close(self): if self.recorder and self.recorder.is_recording: self._stop_recording() self.root.destroy()

🧩 扩展方向

这套基础框架搭好之后,可以往很多方向延伸:

  • 加入音频录制:用 pyaudio 同步采集麦克风或系统音频,再用 FFmpeg 合并音视频轨道
  • 自定义录制区域:在界面上拖拽选框,把坐标传给 ImageGrab.grab(bbox=...)
  • GIF 导出:把帧序列用 Pillow 的 save() 方法直接导出为 GIF,适合录制短片段用于文档说明
  • 定时录制:加一个倒计时启动功能,方便录制需要提前准备的操作

📌 三句话总结

  1. Tkinter + Pillow + OpenCV 这套组合,300 行代码就能实现一个生产可用的录屏工具。
  2. 帧率稳定的关键在于精确的时间控制(perf_counter)和截图/编码的解耦(双缓冲队列)。
  3. GUI 和录制逻辑必须分线程,Tkinter 的 UI 操作必须回到主线程执行——这两条是避坑的核心原则。

#Python #Tkinter #屏幕录制 #OpenCV #多线程编程

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!