目录
Python|GIF 解析与构建(5):手搓截屏和帧率控制
一、引言
在 GIF 构建场景中,实时捕获屏幕画面并精准控制帧率是核心需求之一。本文将基于 Windows API,通过ctypes
库实现自定义截屏功能,并结合时间管理实现帧率控制,为后续 GIF 动态图生成奠定基础。
二、技术实现:手搓截屏模块
2.1 核心原理
利用 Windows API 中的 GDI(图形设备接口)实现屏幕像素数据抓取,主要流程:
- 获取屏幕设备上下文(DC)
- 创建兼容位图(Bitmap)存储截图数据
- 通过
BitBlt
函数复制屏幕区域像素 - 解析位图数据获取 RGB 像素值
2.2 代码解析:ScreenshotData
类
class ScreenshotData():
def __init__(self):
# 加载系统库
self.gdi32 = ctypes.windll.gdi32
self.user32 = ctypes.windll.user32
# 获取屏幕尺寸(含DPI缩放)
SM_CXSCREEN, SM_CYSCREEN = 0, 1
hdc = self.user32.GetDC(None)
try:
dpi = self.gdi32.GetDeviceCaps(hdc, 88) # 获取DPI
zoom = dpi / 96.0 # 计算缩放比例
finally:
self.user32.ReleaseDC(None, hdc)
self.screenWidth = int(self.user32.GetSystemMetrics(SM_CXSCREEN) * zoom)
self.screenHeight = int(self.user32.GetSystemMetrics(SM_CYSCREEN) * zoom)
- 关键点:
- 通过
GetDeviceCaps
获取 DPI,解决高分屏缩放问题 GetSystemMetrics
获取原始屏幕分辨率,结合缩放比例计算实际尺寸
- 通过
2.2.1 截图函数:capture_screen
def capture_screen(self, x, y, width, height):
hwnd = self.user32.GetDesktopWindow() # 获取桌面窗口句柄
hdc_src = self.user32.GetDC(hwnd) # 获取源设备上下文
# 创建兼容内存DC和位图
hdc_dest = self.gdi32.CreateCompatibleDC(hdc_src)
bmp = self.gdi32.CreateCompatibleBitmap(hdc_src, width, height)
old_bmp = self.gdi32.SelectObject(hdc_dest, bmp)
# 复制屏幕区域(SRCCOPY为直接像素复制)
self.gdi32.BitBlt(hdc_dest, 0, 0, width, height, hdc_src, x, y, 0x00CC0020)
# 解析位图像素数据(24位RGB格式)
class RGBQUAD(ctypes.Structure): ... # 颜色结构体定义
class BITMAPINFOHEADER(ctypes.Structure): ... # 位图信息头定义
bmi = BITMAPINFO()
bmi.bmiHeader = BITMAPINFOHEADER(
ctypes.sizeof(BITMAPINFOHEADER), width, -height, 1, 24,
0, 0, 0, 0, 0, 0
)
pixel_data = (ctypes.c_ubyte * (width * height * 3))() # 3字节/像素(RGB)
# 获取像素数据
if not self.gdi32.GetDIBits(hdc_src, bmp, 0, height, pixel_data, ctypes.byref(bmi), 0):
print("GetDIBits failed:", ctypes.WinError())
# 资源释放
self.gdi32.SelectObject(hdc_dest, old_bmp)
self.gdi32.DeleteDC(hdc_dest)
self.user32.ReleaseDC(hwnd, hdc_src)
self.gdi32.DeleteObject(bmp)
return pixel_data
- 核心步骤:
BitBlt
实现屏幕区域拷贝(参数0x00CC0020
为 SRCCOPY 模式)- 通过
BITMAPINFOHEADER
定义位图格式(24 位真彩色,负高度表示自底向上存储) GetDIBits
获取原始像素数据(BGR 顺序,需后续转换为 RGB)
三、技术实现:帧率控制模块
3.1 原理:基于时间差的帧率控制
通过记录每帧开始时间,计算实际耗时并与目标帧率时间比较,通过sleep
补正时间差,公式:
- 目标单帧时间:
time_one_frame = 1 / fps
- 实际耗时:
spend = 当前时间 - 开始时间
- 补正时间:
max(0, time_one_frame - spend)
3.2 代码解析:control_frame
类
class control_frame():
def __init__(self):
self.start_time = 0.0 # 帧开始时间
self.fps = 10 # 目标帧率
self.time_one_frame = 1/self.fps # 单帧时间
self.fps_count = 0 # 总帧数
self.time_all = time.time() # 启动时间
def start(self):
self.start_time = time.time() # 记录帧开始时间
self.fps_count += 1
def spend(self):
return time.time() - self.start_time # 计算已用时间
def wait(self):
spend = self.spend()
# 动态计算实际帧率(用于监控)
true_frame = self.fps_count / (time.time() - self.time_all)
if true_frame > self.fps:
# 补正时间差,确保不超过目标帧率
if self.time_one_frame - spend > 0:
time.sleep(self.time_one_frame - spend)
- 优势:
- 动态监控实际帧率(
true_frame
) - 自适应系统负载,通过
sleep
柔性控制帧率
- 动态监控实际帧率(
四、效果验证:截屏与帧率测试
4.1 测试代码
s = ScreenshotData()
wait = control_frame()
wait.fps = 20 # 设置目标帧率20FPS
frame_number = 100 # 捕获100帧
st = time.time()
for i in range(1, frame_number+1):
wait.start()
# 捕获屏幕区域(0,0)到(20,20)
data = s.capture_screen(0, 0, 20, 20)
wait.wait()
# 输出实时帧率
print(f"第{i}帧 | 实时帧率:{1 / ((time.time()-st)/i):.2f} FPS")
print(f"总耗时:{time.time()-st:.2f}秒 | 平均帧率:{frame_number/(time.time()-st):.2f} FPS")
五、优化方向与注意事项
截图性能优化:
- 使用
GetDC
/ReleaseDC
时避免频繁调用,可改为缓存设备上下文 - 尝试
CreateDIBSection
替代GetDIBits
,减少内存拷贝
- 使用
帧率控制增强:
- 增加帧率平滑算法(如指数移动平均)
- 支持动态帧率调整(根据系统负载自动降帧)
跨平台适配:
当前仅支持 Windows
六、总结
本文通过ctypes
实现了 Windows 下的自定义截屏,并基于时间管理实现了帧率控制,下一个就是关于tk的录制软件制作。
以下是完整代码:
import ctypes # 获取屏幕数据 class ScreenshotData(): def __init__(self): self.gdi32 = ctypes.windll.gdi32 self.user32 = ctypes.windll.user32 # 定义常量 SM_CXSCREEN = 0 SM_CYSCREEN = 1 # 缩放比例 zoom = 1 hdc = self.user32.GetDC(None) try: dpi = self.gdi32.GetDeviceCaps(hdc, 88) zoom = dpi / 96.0 finally: self.user32.ReleaseDC(None, hdc) self.screenWidth = int(self.user32.GetSystemMetrics(SM_CXSCREEN) * zoom) self.screenHeight = int(self.user32.GetSystemMetrics(SM_CYSCREEN) * zoom) # 屏幕截取 def capture_screen(self, x, y, width, height): # 获取桌面窗口句柄 hwnd = self.user32.GetDesktopWindow() # 获取桌面窗口的设备上下文 hdc_src = self.user32.GetDC(hwnd) if len(str(hdc_src)) > 16: return 0 # 创建一个与屏幕兼容的内存设备上下文 hdc_dest = self.gdi32.CreateCompatibleDC(hdc_src) # 创建一个位图 bmp = self.gdi32.CreateCompatibleBitmap(hdc_src, width, height) # 将位图选入内存设备上下文 old_bmp = self.gdi32.SelectObject(hdc_dest, bmp) # 定义SRCCOPY常量 SRCCOPY = 0x00CC0020 # 捕获屏幕 self.gdi32.BitBlt(hdc_dest, 0, 0, width, height, hdc_src, x, y, SRCCOPY) """ gdi32.BitBlt(hdc_src, # 目标设备上下文 x_dest, # 目标矩形左上角的x坐标 y_dest, # 目标矩形左上角的y坐标 width, # 宽度 height, # 高度 hdc_dest, # 源设备上下文 x_src, # 源矩形左上角的x坐标(通常是0) y_src, # 源矩形左上角的y坐标(通常是0) SRCCOPY) # 复制选项 """ # 定义 RGBQUAD 结构体 class RGBQUAD(ctypes.Structure): _fields_ = [("rgbBlue", ctypes.c_ubyte), ("rgbGreen", ctypes.c_ubyte), ("rgbRed", ctypes.c_ubyte), ("rgbReserved", ctypes.c_ubyte)] # 定义 BITMAPINFOHEADER 结构体 class BITMAPINFOHEADER(ctypes.Structure): _fields_ = [("biSize", ctypes.c_uint), ("biWidth", ctypes.c_int), ("biHeight", ctypes.c_int), ("biPlanes", ctypes.c_ushort), ("biBitCount", ctypes.c_ushort), ("biCompression", ctypes.c_uint), ("biSizeImage", ctypes.c_uint), ("biXPelsPerMeter", ctypes.c_int), ("biYPelsPerMeter", ctypes.c_int), ("biClrUsed", ctypes.c_uint), ("biClrImportant", ctypes.c_uint)] # 定义 BITMAPINFO 结构体 class BITMAPINFO(ctypes.Structure): _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", RGBQUAD * 3)] # 只分配了3个RGBQUAD的空间 BI_RGB = 0 DIB_RGB_COLORS = 0 # 分配像素数据缓冲区(这里以24位位图为例,每个像素3字节) pixel_data = (ctypes.c_ubyte * (width * height * 3))() # 4 # 填充 BITMAPINFO 结构体 bmi = BITMAPINFO() bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) bmi.bmiHeader.biWidth = width bmi.bmiHeader.biHeight = -height # 注意:负高度表示自底向上的位图 bmi.bmiHeader.biPlanes = 1 bmi.bmiHeader.biBitCount = 24 # 24即3*8 32 bmi.bmiHeader.biCompression = BI_RGB # 无压缩 # 调用 GetDIBits 获取像素数据 ret = self.gdi32.GetDIBits(hdc_src, bmp, 0, height, pixel_data, ctypes.byref(bmi), DIB_RGB_COLORS) if ret == 0: print("GetDIBits failed:", ctypes.WinError()) # 恢复设备上下文 self.gdi32.SelectObject(hdc_dest, old_bmp) # 删除内存设备上下文 self.gdi32.DeleteDC(hdc_dest) # 释放桌面窗口的设备上下文 self.user32.ReleaseDC(hwnd, hdc_src) # bmp已经被处理,现在删除它 self.gdi32.DeleteObject(bmp) return pixel_data import time # 控制帧率 class control_frame(): def __init__(self): self.start_time = float() # 每次启动时间 self.fps = int(10) # fps self.time_one_frame = 1 / self.fps # 补正时间 self.fps_count = 0 # 总帧率 self.time_all = time.time() # 启动时间 # 启动 def start(self): self.start_time = time.time() self.fps_count += 1 # 花销 def spend(self): spend = time.time() - self.start_time return spend # 等待 def wait(self): spend = self.spend() true_frame = self.fps_count / (time.time() - self.time_all) if true_frame > self.fps: if self.time_one_frame - spend > 0: time.sleep(self.time_one_frame - spend) s = ScreenshotData() wait = control_frame() # 帧率 wait.fps = 20 # 总帧率 frame_number = 100 st = time.time() for i in range(1, frame_number + 1): wait.start() data = s.capture_screen(0, 0, 20, 20) wait.wait() print("帧率输出:", 1 / ((time.time() - st) / i)) print("花费时间:", time.time() - st)
import ctypes
# 获取屏幕数据
class ScreenshotData():
def __init__(self):
self.gdi32 = ctypes.windll.gdi32
self.user32 = ctypes.windll.user32
# 定义常量
SM_CXSCREEN = 0
SM_CYSCREEN = 1
# 缩放比例
zoom = 1
hdc = self.user32.GetDC(None)
try:
dpi = self.gdi32.GetDeviceCaps(hdc, 88)
zoom = dpi / 96.0
finally:
self.user32.ReleaseDC(None, hdc)
self.screenWidth = int(self.user32.GetSystemMetrics(SM_CXSCREEN) * zoom)
self.screenHeight = int(self.user32.GetSystemMetrics(SM_CYSCREEN) * zoom)
# 屏幕截取
def capture_screen(self, x, y, width, height):
# 获取桌面窗口句柄
hwnd = self.user32.GetDesktopWindow()
# 获取桌面窗口的设备上下文
hdc_src = self.user32.GetDC(hwnd)
if len(str(hdc_src)) > 16:
return 0
# 创建一个与屏幕兼容的内存设备上下文
hdc_dest = self.gdi32.CreateCompatibleDC(hdc_src)
# 创建一个位图
bmp = self.gdi32.CreateCompatibleBitmap(hdc_src, width, height)
# 将位图选入内存设备上下文
old_bmp = self.gdi32.SelectObject(hdc_dest, bmp)
# 定义SRCCOPY常量
SRCCOPY = 0x00CC0020
# 捕获屏幕
self.gdi32.BitBlt(hdc_dest, 0, 0, width, height, hdc_src, x, y, SRCCOPY)
"""
gdi32.BitBlt(hdc_src, # 目标设备上下文
x_dest, # 目标矩形左上角的x坐标
y_dest, # 目标矩形左上角的y坐标
width, # 宽度
height, # 高度
hdc_dest, # 源设备上下文
x_src, # 源矩形左上角的x坐标(通常是0)
y_src, # 源矩形左上角的y坐标(通常是0)
SRCCOPY) # 复制选项
"""
# 定义 RGBQUAD 结构体
class RGBQUAD(ctypes.Structure):
_fields_ = [("rgbBlue", ctypes.c_ubyte),
("rgbGreen", ctypes.c_ubyte),
("rgbRed", ctypes.c_ubyte),
("rgbReserved", ctypes.c_ubyte)]
# 定义 BITMAPINFOHEADER 结构体
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [("biSize", ctypes.c_uint),
("biWidth", ctypes.c_int),
("biHeight", ctypes.c_int),
("biPlanes", ctypes.c_ushort),
("biBitCount", ctypes.c_ushort),
("biCompression", ctypes.c_uint),
("biSizeImage", ctypes.c_uint),
("biXPelsPerMeter", ctypes.c_int),
("biYPelsPerMeter", ctypes.c_int),
("biClrUsed", ctypes.c_uint),
("biClrImportant", ctypes.c_uint)]
# 定义 BITMAPINFO 结构体
class BITMAPINFO(ctypes.Structure):
_fields_ = [("bmiHeader", BITMAPINFOHEADER),
("bmiColors", RGBQUAD * 3)] # 只分配了3个RGBQUAD的空间
BI_RGB = 0
DIB_RGB_COLORS = 0
# 分配像素数据缓冲区(这里以24位位图为例,每个像素3字节)
pixel_data = (ctypes.c_ubyte * (width * height * 3))() # 4
# 填充 BITMAPINFO 结构体
bmi = BITMAPINFO()
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bmi.bmiHeader.biWidth = width
bmi.bmiHeader.biHeight = -height # 注意:负高度表示自底向上的位图
bmi.bmiHeader.biPlanes = 1
bmi.bmiHeader.biBitCount = 24 # 24即3*8 32
bmi.bmiHeader.biCompression = BI_RGB # 无压缩
# 调用 GetDIBits 获取像素数据
ret = self.gdi32.GetDIBits(hdc_src, bmp, 0, height, pixel_data, ctypes.byref(bmi), DIB_RGB_COLORS)
if ret == 0:
print("GetDIBits failed:", ctypes.WinError())
# 恢复设备上下文
self.gdi32.SelectObject(hdc_dest, old_bmp)
# 删除内存设备上下文
self.gdi32.DeleteDC(hdc_dest)
# 释放桌面窗口的设备上下文
self.user32.ReleaseDC(hwnd, hdc_src)
# bmp已经被处理,现在删除它
self.gdi32.DeleteObject(bmp)
return pixel_data
import time
# 控制帧率
class control_frame():
def __init__(self):
self.start_time = float() # 每次启动时间
self.fps = int(10) # fps
self.time_one_frame = 1 / self.fps # 补正时间
self.fps_count = 0 # 总帧率
self.time_all = time.time() # 启动时间
# 启动
def start(self):
self.start_time = time.time()
self.fps_count += 1
# 花销
def spend(self):
spend = time.time() - self.start_time
return spend
# 等待
def wait(self):
spend = self.spend()
true_frame = self.fps_count / (time.time() - self.time_all)
if true_frame > self.fps:
if self.time_one_frame - spend > 0:
time.sleep(self.time_one_frame - spend)
s = ScreenshotData()
wait = control_frame()
# 帧率
wait.fps = 20
# 总帧率
frame_number = 100
st = time.time()
for i in range(1, frame_number + 1):
wait.start()
data = s.capture_screen(0, 0, 20, 20)
wait.wait()
print("帧率输出:", 1 / ((time.time() - st) / i))
print("花费时间:", time.time() - st)