先说结论:需要,而且非常值得。
市面上的录屏软件要么臃肿、要么收费、要么在某些企业内网环境下根本装不上。作为 Python 开发者,我们手里有 Tkinter、有 OpenCV、有 threading——完全可以在一个下午的时间里,从零撸出一个轻量、可控、可二次开发的屏幕录制工具。
我在给内部团队做技术分享录制时,就踩过这个坑:OBS 太重,ShareX 在某台老机器上崩溃,最后索性自己写。写完之后发现,不过 300 行代码,性能却出乎意料地稳。帧率稳在 25fps,CPU 占用不超过 15%。这篇文章,就把这套思路完整拆给你看。
核心依赖只有三个:
ImageGrab.grab() 在 Windows 下性能相当可观VideoWriter 支持多种编解码器有人会问,为什么不用 pyautogui 截图?原因很简单——pyautogui.screenshot() 底层也是调 PIL,但多了一层封装,速度反而更慢。直接用 ImageGrab 是最短路径。
另外,帧率控制这块,咱们用 threading.Event 配合时间戳对齐,而不是简单粗暴地 time.sleep()。这个细节差别很大,后面会详细讲。
bashpip 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这是整个项目最关键的部分。帧率控制的精髓在这里。
pythonimport 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界面不求华丽,但信息要到位:录制状态、实际帧率、已录帧数,一眼就能看清楚。
pythonimport 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.pypythonfrom ui import RecorderUI
if __name__ == "__main__":
app = RecorderUI()
app.run()

就这三行。干净。
这是最常见的问题。你设置了 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():
pythonself.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=...)save() 方法直接导出为 GIF,适合录制短片段用于文档说明perf_counter)和截图/编码的解耦(双缓冲队列)。#Python #Tkinter #屏幕录制 #OpenCV #多线程编程
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!