I'm trying to add a high FPS screen recorder to my application.
I use Python 3.7 on Windows.
The modules and methods I've tried are mss (python-mss) and d3dshot, but I'm still only achieving 15-19 FPS for a long video (more than 20 seconds).
The resolution I'm recording at is 1920 x 1080.
What is the best way to optimize screen recording? I've tried to use the multiprocessing library, but it seems like it's still not fast enough. I'm not sure I'm using it in the optimal way, what are some ways I could use it to improve processing performance?
Using OBS Studio, I'm able to get 30 FPS, no matter how long the video is. My objective is to achieve the same results with my own code.
Here is what I've written so far:
from multiprocessing import Process, Queue
from time import sleep, time
import cv2
import d3dshot
import numpy as np
def grab(queue):
d = d3dshot.create(capture_output="numpy", frame_buffer_size=500)
d.capture()
sleep(0.1)
c=0
begin = time()
while time() - begin < 30:
starter = time()
frame = d.get_latest_frame()
queue.put(frame)
c+=1
ender = time()
sleep(max(0, 1/60 - (ender -starter)))
# Tell the other worker to stop
queue.put(None)
final=time()
print(c/(final-begin))
d.stop()
def save(queue):
SCREEN_SIZE = 1920, 1080
# Define the codec and create VideoWriter object
fourcc = cv2.VideoWriter_fourcc(*'DIVX') # In Windows: DIVX
out = cv2.VideoWriter(r"output.avi",fourcc, 30.0, (SCREEN_SIZE))
# type: (Queue) -> None
last_img = None
while "there are screenshots":
img = queue.get()
if img is None:
break
if img is last_img:
continue
out.write(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
last_img = img
if __name__ == "__main__":
# The screenshots queue
queue = Queue() # type: Queue
# 2 processes: one for grabing and one for saving PNG files
Process(target=grab, args=(queue,)).start()
Process(target=save, args=(queue,)).start()
The goal is to capture a game, while performing automated keyboard and mouse actions.
I have faced the same problem in trying to get high speed recording for games. This was the fastest solution I was able to find for Windows. The code is using raw buffer objects and leads to around ~27 FPS. I cannot find the original post on which this code is based, but if someone finds it I will add the reference.
Note that the framerate will significantly increase if you make the region smaller than 1920x1080.
"""
Alternative screen capture device, when there is no camera of webcam connected
to the desktop.
"""
import logging
import sys
import time
import cv2
import numpy as np
if sys.platform == 'win32':
import win32gui, win32ui, win32con, win32api
else:
logging.warning(f"Screen capture is not supported on platform: `{sys.platform}`")
from collections import namedtuple
class ScreenCapture:
"""
Captures a fixed region of the total screen. If no region is given
it will take the full screen size.
region_ltrb: Tuple[int, int, int, int]
Specific region that has to be taken from the screen using
the top left `x` and `y`, bottom right `x` and `y` (ltrb coordinates).
"""
__region = namedtuple('region', ('x', 'y', 'width', 'height'))
def __init__(self, region_ltrb=None):
self.region = region_ltrb
self.hwin = win32gui.GetDesktopWindow()
# Time management
self._time_start = time.time()
self._time_taken = 0
self._time_average = 0.04
def __getitem__(self, item):
return self.screenshot()
def __next__(self):
return self.screenshot()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
if exc_type and isinstance(exc_val, StopIteration):
return True
return False
#staticmethod
def screen_dimensions():
""" Retrieve total screen dimensions. """
left = win32api.GetSystemMetrics(win32con.SM_XVIRTUALSCREEN)
top = win32api.GetSystemMetrics(win32con.SM_YVIRTUALSCREEN)
height = win32api.GetSystemMetrics(win32con.SM_CYVIRTUALSCREEN)
width = win32api.GetSystemMetrics(win32con.SM_CXVIRTUALSCREEN)
return left, top, height, width
#property
def fps(self):
return int(1 / self._time_average) * (self._time_average > 0)
#property
def region(self):
return self._region
#property
def size(self):
return self._region.width, self._region.height
#region.setter
def region(self, value):
if value is None:
self._region = self.__region(*self.screen_dimensions())
else:
assert len(value) == 4, f"Region requires 4 input, x, y of left top, and x, y of right bottom."
left, top, x2, y2 = value
width = x2 - left + 1
height = y2 - top + 1
self._region = self.__region(*list(map(int, (left, top, width, height))))
def screenshot(self, color=None):
"""
Takes a part of the screen, defined by the region.
:param color: cv2.COLOR_....2...
Converts the created BGRA image to the requested image output.
:return: np.ndarray
An image of the region in BGRA values.
"""
left, top, width, height = self._region
hwindc = win32gui.GetWindowDC(self.hwin)
srcdc = win32ui.CreateDCFromHandle(hwindc)
memdc = srcdc.CreateCompatibleDC()
bmp = win32ui.CreateBitmap()
bmp.CreateCompatibleBitmap(srcdc, width, height)
memdc.SelectObject(bmp)
memdc.BitBlt((0, 0), (width, height), srcdc, (left, top), win32con.SRCCOPY)
signed_ints_array = bmp.GetBitmapBits(True)
img = np.frombuffer(signed_ints_array, dtype='uint8')
img.shape = (height, width, 4)
srcdc.DeleteDC()
memdc.DeleteDC()
win32gui.ReleaseDC(self.hwin, hwindc)
win32gui.DeleteObject(bmp.GetHandle())
# This makes sure that the FPS are taken in comparison to screenshots rates and vary only slightly.
self._time_taken, self._time_start = time.time() - self._time_start, time.time()
self._time_average = self._time_average * 0.95 + self._time_taken * 0.05
if color is not None:
return cv2.cvtColor(img, color)
return img
def show(self, screenshot=None):
""" Displays an image to the screen. """
image = screenshot if screenshot is not None else self.screenshot()
cv2.imshow('Screenshot', image)
if cv2.waitKey(1) & 0xff == ord('q'):
raise StopIteration
return image
def close(self):
""" Needs to be called before exiting when `show` is used, otherwise an error will occur. """
cv2.destroyWindow('Screenshot')
def scale(self, src: np.ndarray, size: tuple):
return cv2.resize(src, size, interpolation=cv2.INTER_LINEAR_EXACT)
def save(self, path, screenshot=None):
""" Store the current screenshot in the provided path. Full path, with img name is required.) """
image = screenshot if screenshot is not None else self.screenshot
cv2.imwrite(filename=path, img=image)
if __name__ == '__main__':
# Example usage when displaying.
with ScreenCapture((0, 0, 1920, 1080)) as capture:
for _ in range(100):
capture.show()
print(f"\rCapture framerate: {capture.fps}", end='')
# Example usage as generator.
start_time = time.perf_counter()
for frame, screenshot in enumerate(ScreenCapture((0, 0, 1920, 1080)), start=1):
print(f"\rFPS: {frame / (time.perf_counter() - start_time):3.0f}", end='')
Edit
I noticed some small mistake in the window show function, and the self.screenshot calls in the __getitem__ and __next__ method. These have been resolved.
Next to the for example using the ScreenCapture as a context manager, I added an example of using it as a generator.
Related
I am trying to read all the touching pixels with the same color in a image.
For that I use reccursive functions. When I check one pixel, I look on the right, left, top and bottom if the pixel close to it is the same color. If it is I add it to an array otherwise I don't.
The code is as follow:
vimport tkinter as tk
from PIL import Image
import sys
sys.setrecursionlimit(200000)
## WINDOWS
# to launch in debug mode
imgToDraw = Image.open('assets-test\\smile-face.png')
# to launch normaly
# imgToDraw = Image.open('..\\assets-test\\smile-face.png')
## LINUX
# imgToDraw = Image.open('../assets-test/smile-face.png')
imgPixels = imgToDraw.load()
imgWidth = imgToDraw.size[0]
imgHeight = imgToDraw.size[1]
# an element is a part of the image, it's a bunch of pixels with approximately the same color
# and each pixel touch at least one other pixel of the same element
elements = [];
isPixelChecked = [[ False for y in range( imgWidth ) ] for x in range( imgHeight )]
# min tolerable difference between two colors to consider them the same
# the higher the value is the more colors will be considered the same
COLOR_TOLERANCE = 10
reccursionCount = 0
class Element:
def __init__(self, color):
self.pixels = [];
self.color = color;
def addPixel(self, pixel):
self.pixels.append(pixel);
class Pixel:
def __init__(self, x, y, color):
self.x = x # x position of the pixel
self.y = y # y position of the pixel
self.color = color # color is a tuple (r,g,b)
def cutImageInElements():
global element
completeElement(element.pixels)
def completeElement(elemPixels):
global reccursionCount
global isPixelChecked
reccursionCount += 1
nbPixels = len(elemPixels);
xIndex = elemPixels[nbPixels - 1].x
yIndex = elemPixels[nbPixels - 1].y
xRightIdx = elemPixels[nbPixels - 1].x + 1
xLeftIdx = elemPixels[nbPixels - 1].x - 1
yBottomIdx = elemPixels[nbPixels - 1].y + 1
yTopIdx = elemPixels[nbPixels - 1].y - 1
isPixelChecked[xIndex][yIndex] = True
if((xRightIdx < imgWidth) and isPixelChecked[xRightIdx][yIndex] == False):
if(isColorAlmostSame(imgPixels[elemPixels[0].x, elemPixels[0].y], imgPixels[xRightIdx, yIndex])):
pixelAppended = Pixel(xRightIdx, yIndex, imgPixels[xRightIdx, yIndex])
elemPixels.append(pixelAppended)
completeElement(elemPixels)
if((xLeftIdx >= 0) and isPixelChecked[xLeftIdx][yIndex] == False):
if(isColorAlmostSame(imgPixels[elemPixels[0].x, elemPixels[0].y], imgPixels[xLeftIdx, yIndex])):
pixelAppended = Pixel(xLeftIdx, yIndex, imgPixels[xLeftIdx, yIndex])
elemPixels.append(pixelAppended)
completeElement(elemPixels)
if((yBottomIdx < imgHeight) and isPixelChecked[xIndex][yBottomIdx] == False):
if(isColorAlmostSame(imgPixels[elemPixels[0].x, elemPixels[0].y], imgPixels[xIndex, yBottomIdx])):
pixelAppended = Pixel(xIndex, yBottomIdx, imgPixels[xIndex, yBottomIdx])
elemPixels.append(pixelAppended)
completeElement(elemPixels)
if((yTopIdx >= 0) and isPixelChecked[xIndex][yTopIdx] == False):
if(isColorAlmostSame(imgPixels[elemPixels[0].x, elemPixels[0].y], imgPixels[xIndex, yTopIdx])):
pixelAppended = Pixel(xIndex, yTopIdx, imgPixels[xIndex, yTopIdx])
elemPixels.append(pixelAppended)
completeElement(elemPixels)
def isColorAlmostSame(pixel1, pixel2):
redDiff = abs(pixel1[0] - pixel2[0])
greenDiff = abs(pixel1[1] - pixel2[1])
blueDiff = abs(pixel1[2] - pixel2[2])
if(redDiff < COLOR_TOLERANCE and greenDiff < COLOR_TOLERANCE and blueDiff < COLOR_TOLERANCE):
return True
else:
return False
def printPixelsArr(pixelsArr):
for x in range(0, len(pixelsArr)):
print(pixelsArr[x].x, pixelsArr[x].y, pixelsArr[x].color)
if __name__ == '__main__':
pixel = Pixel(0, 0, imgPixels[0, 0]);
element = Element(pixel.color);
element.addPixel(pixel);
cutImageInElements();
print("NbReccursive call: ", reccursionCount)
This code works for small images of size 100x100 but crashes with an image of 400x400 with the error "terminated by signal SIGSEGV (Address boundary error)" when I launch the program on wsl2. When I run the program on cmd or powershell it just crashes but with no error code/msg.
I cannot understand why it would work with some size of images and not others. I can only think that the memory runs out or something but in the task manager the program uses almost no memory.
Not sure why that's failing, but that much recursion in Python isn't a great idea. I'd suggest reading about tail recursion that other languages use to make some recursive algorithms consume constant stack space. Note that your algorithm is not tail recursive, so this optimisation wouldn't help even if Python supported it.
I hacked together the following flood fill implementation. It uses Numpy so that it's only 10x slower than Pillow's ImageDraw.floodfill.
import numpy as np
def floodfill(im, row, col, threshold):
similar = np.mean(np.abs(im - im[row, col]), 2) < threshold
mask = np.zeros_like(similar)
mask[row, col] = 1
m2 = mask.copy()
while True:
m2[:,:] = mask
m2[1:,:] |= mask[:-1]
m2[:-1,:] |= mask[1:]
m2[:,1:] |= mask[:,:-1]
m2[:,:-1] |= mask[:,1:]
m2 &= similar
if np.all(m2 == mask):
return mask
mask[:,:] = m2
As an example of using this, you could do;
import requests
from io import BytesIO
res = requests.get("https://picsum.photos/300")
res.raise_for_status()
src = Image.open(BytesIO(res.content))
mask = floodfill(np.array(src, int), 10, 10, 40)
where the random image I got and the output mask are:
I'm trying to save both, the depth and color images of the Intel Realsense D435i camera in a list of 300 images. Then I will use multiprocessing to save this chunk of 300 images onto my disk. But every time I try, the program successfully appends 15 images in the list and then I get this error:
Frame didn't arrived within 5000
I made sure I had the 64 bit version on python 3.6 installed and the camera streams perfectly well when I do not try to save the images in a list. The real-sense viewer works great too. I also tried with different resolutions and frame rates but it doesn't seem to work either. What is interesting is if I only save the color images, I will not get the same error, instead I will get the same color image over and over in the list.
if __name__ == '__main__':
pipeline = rs.pipeline()
config = rs.config()
config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30)
config.enable_stream(rs.stream.color, 1280, 720, rs.format.bgr8, 30)
profile = pipeline.start(config)
depth_sensor = profile.get_device().first_depth_sensor()
depth_sensor.set_option(
rs.option.visual_preset, 3
) # Set high accuracy for depth sensor
depth_scale = depth_sensor.get_depth_scale()
align_to = rs.stream.color
align = rs.align(align_to)
# Init variables
im_count = 0
image_chunk = []
image_chunk2 = []
# sentinel = True
try:
while True:
# Wait for a coherent pair of frames: depth and color
frames = pipeline.wait_for_frames()
aligned_frames = align.process(frames)
aligned_depth_frame = aligned_frames.get_depth_frame()
color_frame = aligned_frames.get_color_frame()
if not aligned_depth_frame or not color_frame:
print("problem here")
raise RuntimeError("Could not acquire depth or color frames.")
depth_image = np.asanyarray(aligned_depth_frame.get_data())
color_image = np.asanyarray(color_frame.get_data())
image_chunk.append(color_image)
image_chunk2.append(depth_image)
except Exception as e:
print(e)
finally:
# Stop streaming
pipeline.stop()
I simply need it to save 300 images in a row, that's all, so I am quite troubled as to what is causing this issue.
Holding onto the frame locks the memory, and eventually it hits a limit, which prevents acquiring more images. Even though you are creating an image, the data is still from the frame. You need to clone the image after you create it to release the link to the frame's memory.
depth_image = np.asanyarray(aligned_depth_frame.get_data())
color_image = np.asanyarray(color_frame.get_data())
depth_image = depth_image.copy()
color_image = color_image.copy()
image_chunk.append(color_image)
image_chunk2.append(depth_image)
Read more on frames and memory management here:
https://dev.intelrealsense.com/docs/frame-management
I created a wrapper class to extract the various elements out of the frame set that can't be recreated later. It's a bit heavy, but shows some common operations that might be helpful for others:
colorizer = None
align_to_depth = None
align_to_color = None
pointcloud = rs.pointcloud()
class IntelD435ImagePacket:
"""
Class that contains image and associated processing data.
"""
#property
def frame_id(self):
return self._frame_id
#property
def timestamp(self):
return self._timestamp
#property
def image_color(self):
return self._image_color
#property
def image_depth(self):
return self._image_depth
#property
def image_color_aligned(self):
return self._image_color_aligned
#property
def image_depth_aligned(self):
return self._image_depth_aligned
#property
def image_depth_colorized(self):
if not self._image_depth_colorized:
self._image_depth_colorized = cv2.applyColorMap(self.image_depth, cv2.COLORMAP_JET);
return self._image_depth_colorized
#property
def intrinsics(self):
return self._intrinsics
#property
def pointcloud(self):
return self._pointcloud
#property
def pointcloud_texture(self):
return self._pointcloud_texture
def _rs_intrinsics_to_opencv_matrix(self, rs_intrinsics):
fx = rs_intrinsics.fx
fy = rs_intrinsics.fy
cx = rs_intrinsics.ppx
cy = rs_intrinsics.ppy
s = 0 # skew
return np.array([fx, s, cx,
0, fy, cy,
0, 0, 1]).reshape(3, 3)
def __init__(self, frame_set, frame_id=None, timestamp=None, *args, **kwargs):
global colorizer
if not colorizer:
colorizer = rs.colorizer()
colorizer.set_option(rs.option.color_scheme, 0)
global align_to_depth
if not align_to_depth:
align_to_depth = rs.align(rs.stream.depth)
global align_to_color
if not align_to_color:
align_to_color = rs.align(rs.stream.color)
global pointcloud
if not pointcloud:
pointcloud = rs.pointcloud()
# Get intrinsics
profile = frame_set.get_profile()
video_stream_profile = profile.as_video_stream_profile()
rs_intrinsics = video_stream_profile.get_intrinsics()
self._intrinsics = self._rs_intrinsics_to_opencv_matrix(rs_intrinsics)
# Get pointcloud
depth_frame = frame_set.get_depth_frame()
color_frame = frame_set.get_color_frame()
pointcloud.map_to(color_frame)
points = pointcloud.calculate(depth_frame)
vtx = np.asanyarray(points.get_vertices())
points_arr = vtx.view(np.float32).reshape(vtx.shape + (-1,)).copy()
self._pointcloud = points_arr
# Get pointcloud texture mapping
tex = np.asanyarray(points.get_texture_coordinates())
color_map_arr = tex.view(np.float32).reshape(tex.shape + (-1,)).copy()
self._pointcloud_texture = color_map_arr
# Extract color image
color_frame = frame_set.get_color_frame()
self._image_color = np.asanyarray(color_frame.get_data()).copy()
# Extract depth image
depth_frame = frame_set.get_depth_frame()
self._image_depth = np.asanyarray(depth_frame.get_data()).copy()
# Align the color frame to depth frame and extract color image
color_frame_aligned = align_to_depth.process(frame_set).get_color_frame()
self._image_color_aligned = np.asanyarray(color_frame_aligned.get_data()).copy()
# Align the depth frame to color frame and extract depth image
depth_frame_aligned = align_to_color.process(frame_set).get_depth_frame()
self._image_depth_aligned = np.asanyarray(depth_frame_aligned.get_data()).copy()
self._image_depth_colorized = None
if frame_id:
self._frame_id = frame_id
else:
self._frame_id = frame_set.frame_number
if timestamp:
self._timestamp = timestamp
else:
self._timestamp = frame_set.timestamp
self.__dict__.update(kwargs)
I've been struggling to come up with a script that allows me to take screenshots of my desktop more than once per every second. I'm using Win10.
PIL:
from PIL import ImageGrab
import time
while True:
im = ImageGrab.grab()
fname = "dropfolder/%s.png" %int(time.time())
im.save(fname,'PNG')
Results 1.01 seconds per image.
PyScreeze (https://github.com/asweigart/pyscreeze):
import pyscreeze
import time
while True:
fname = "dropfolder/%s.png" %int(time.time())
x = pyscreeze.screenshot(fname)
Results 1.00 seconds per image.
Win32:
import win32gui
import win32ui
import win32con
import time
w=1920 #res
h=1080 #res
while True:
wDC = win32gui.GetWindowDC(0)
dcObj=win32ui.CreateDCFromHandle(wDC)
cDC=dcObj.CreateCompatibleDC()
dataBitMap = win32ui.CreateBitmap()
dataBitMap.CreateCompatibleBitmap(dcObj, w, h)
cDC.SelectObject(dataBitMap)
cDC.BitBlt((0,0),(w, h) , dcObj, (0,0), win32con.SRCCOPY)
fname = "dropfolder/%s.png" %int(time.time())
dataBitMap.SaveBitmapFile(cDC, fname)
dcObj.DeleteDC()
cDC.DeleteDC()
win32gui.ReleaseDC(0, wDC)
win32gui.DeleteObject(dataBitMap.GetHandle())
Results 1.01 seconds per image.
Then I stumbled into thread (Fastest way to take a screenshot with python on windows) where it was suggested that gtk would yield phenomenal results.
However using gtk:
import gtk
import time
img_width = gtk.gdk.screen_width()
img_height = gtk.gdk.screen_height()
while True:
screengrab = gtk.gdk.Pixbuf(
gtk.gdk.COLORSPACE_RGB,
False,
8,
img_width,
img_height
)
fname = "dropfolder/%s.png" %int(time.time())
screengrab.get_from_drawable(
gtk.gdk.get_default_root_window(),
gtk.gdk.colormap_get_system(),
0, 0, 0, 0,
img_width,
img_height
).save(fname, 'png')
Results 2.34 seconds per image.
It seems to me like I'm doing something wrong, because people have been getting great results with gtk.
Any advices how to speed up the process?
Thanks!
Your first solution should be giving you more than one picture per second. The problem though is that you will be overwriting any pictures that occur within the same second, i.e. they will all have the same filename. To get around this you could create filenames that include 10ths of a second as follows:
from PIL import ImageGrab
from datetime import datetime
while True:
im = ImageGrab.grab()
dt = datetime.now()
fname = "pic_{}.{}.png".format(dt.strftime("%H%M_%S"), dt.microsecond // 100000)
im.save(fname, 'png')
On my machine, this gave the following output:
pic_1143_24.5.png
pic_1143_24.9.png
pic_1143_25.3.png
pic_1143_25.7.png
pic_1143_26.0.png
pic_1143_26.4.png
pic_1143_26.8.png
pic_1143_27.2.png
In case anyone cares in 2022: You can try my newly created project DXcam: I think for raw speed it's the fastest out there (in python, and without going too deep into the rabbit hole). It's originally created for a deep learning pipeline for FPS games where the higher FPS you get the better. Plus I (am trying to) design it to be user-friendly:
For a screenshot just do
import dxcam
camera = dxcam.create()
frame = camera.grab() # full screen
frame = camera.grab(region=(left, top, right, bottom)) # region
For screen capturing:
camera.start(target_fps=60) # threaded
for i in range(1000):
image = camera.get_latest_frame() # Will block until new frame available
camera.stop()
I copied the part of the benchmarks section from the readme:
DXcam
python-mss
D3DShot
Average FPS
238.79
75.87
118.36
Std Dev
1.25
0.5447
0.3224
The benchmarks is conducted through 5 trials on my 240hz monitor with a constant 240hz rendering rate synced w/the monitor (using blurbuster ufo test).
You can read more about the details here: https://github.com/ra1nty/DXcam
This solution uses d3dshot.
def d3dgrab(rect=(0, 0, 0, 0), spath=r".\\pictures\\cache\\", sname="", title=""):
""" take a screenshot by rect. """
sname = sname if sname else time.strftime("%Y%m%d%H%M%S000.jpg", time.localtime())
while os.path.isfile("%s%s" % (spath, sname)):
sname = "%s%03d%s" % (sname[:-7], int(sname[-7:-4]) + 1, sname[-4:])
xlen = win32api.GetSystemMetrics(win32con.SM_CXSCREEN)
ylen = win32api.GetSystemMetrics(win32con.SM_CYSCREEN)
assert 0 <= rect[0] <= xlen and 0 <= rect[2] <= xlen, ValueError("Illegal value of X coordination in rect: %s" % rect)
assert 0 <= rect[1] <= ylen and 0 <= rect[3] <= ylen, ValueError("Illegal value of Y coordinatoin in rect: %s" % rect)
if title:
hdl = win32gui.FindWindow(None, title)
if hdl != win32gui.GetForegroundWindow():
win32gui.SetForegroundWindow(hdl)
rect = win32gui.GetWindowRect(hdl)
elif not sum(rect):
rect = (0, 0, xlen, ylen)
d = d3dshot.create(capture_output="numpy")
return d.screenshot_to_disk(directory=spath, file_name=sname, region=rect)
I think it can be helped
sname = sname if sname else time.strftime("%Y%m%d%H%M%S000.jpg", time.localtime())
while os.path.isfile("%s%s" % (spath, sname)):
sname = "%s%03d%s" % (sname[:-7], int(sname[-7:-4]) + 1, sname[-4:])
And it's fastest way to take screenshot I found.
I like what the turtle module does in Python and I'd like to output the entire animation of it drawing the shape. Is there a way to do this? GIF/MP4/anything that shows the animation. Note, I know that an external screen recorder will do the job, but I'm looking for a way for the turtle module to do this itself.
Make an animated GIF from Python turtle using Preview on OSX
1) Start with a working program
As obvious as that seems, don't be debugging your code while trying to generate the animated GIF. It should be a proper turtle program with no infinite loops that ends with mainloop(), done(), or exitonclick().
The program I'm going to use for this explanation is one I wrote for Programming Puzzles & Golf Code that draws an Icelandic flag using turtle. It's intentionally minimalist as it is PP&GC:
from turtle import *
import tkinter as _
_.ROUND = _.BUTT
S = 8
h = 18 * S
color("navy")
width(h)
fd(25 * S)
color("white")
width(4 * S)
home()
pu()
goto(9 * S, -9 * S)
lt(90)
pd()
fd(h)
color("#d72828")
width(S + S)
bk(h)
pu()
home()
pd()
fd(25 * S)
ht()
done()
2) Have your program save snapshots on a timed basis
Repackage your program with draw(), save() and stop() timed events roughly as follows:
from turtle import *
import tkinter as _
_.ROUND=_.BUTT
def draw():
S = 8
h = 18 * S
color("navy")
width(h)
fd(25 * S)
color("white")
width(4 * S)
home()
pu()
goto(9 * S, -9 * S)
lt(90)
pd()
fd(h)
color("#d72828")
width(S + S)
bk(h)
pu()
home()
pd()
fd(25 * S)
ht()
ontimer(stop, 500) # stop the recording (1/2 second trailer)
running = True
FRAMES_PER_SECOND = 10
def stop():
global running
running = False
def save(counter=[1]):
getcanvas().postscript(file = "iceland{0:03d}.eps".format(counter[0]))
counter[0] += 1
if running:
ontimer(save, int(1000 / FRAMES_PER_SECOND))
save() # start the recording
ontimer(draw, 500) # start the program (1/2 second leader)
done()
I'm using 10 frames per second (FPS) as that will match what Preview uses in a later step.
3) Run your program; quit after it completes.
Create a new, empty directory and run it from there. If all goes to plan, it should dump a series of *.eps files into the directory.
4) Load all these *.eps files into Preview
Assuming Preview is my default previewer, in Terminal.app I would simply do:
open iceland*.eps
5) Select-All the PDF (were EPS) files in the Preview sidebar and File/Export... (not Export as PDF) as GIF
Set the export type under the Options button, save them into our temporary directory. You need to hold down the Option key when selecting a format to see the GIF choice. Pick a good screen resolution. We should now have *.gif files in our temporary directory.
Quit Preview.
6) Load all the *.gif files into Preview
open iceland*.gif
7) Merge all but first GIF file into the first GIF file
Select All the GIF files in Preview's sidebar. Unselect (Command Click) the first GIF file, e.g. iceland001.gif. Drag the selected GIF files onto the unselected GIF file. This will modify it and it's name. Use File/Export... to export the modified first GIF file to a new GIF file, e.g. iceland.gif
8) This is an animated GIF!
Convince yourself by loading it into Safari, e.g.:
open -a Safari iceland.gif
9) Converting to a repeating animated GIF
For a repeating animated GIF, you'll need some external tool like ImageMagick or Gifsicle to set the loop value:
convert -loop 0 iceland.gif iceland-repeating.gif
And again convince yourself that it works:
open -a Safari iceland-repeating.gif
10) Animated GIF result. Good luck!
Main concept
Here is my solution, the step as below,
Get the frame (you need use turtle.ontimer -> turtle.getcanvas().postscript(file=output_file) )
Convert each EPS to PNG. (since turtle.getcanvas().postscript return EPS, so you need use PIL to convert EPS to PNG)
you need download ghostscript: https://www.ghostscript.com/download/gsdnld.html
Make a GIF with your PNG list. (use PIL.ImageFile.ImageFile.save(output_path, format='gif', save_all=True, append_images=, duration, loop)
Script
Here is my script (maybe I will publish to PyPI if I have time...)
import turtle
import tkinter
from typing import Callable, List
from pathlib import Path
import re
import os
import sys
import functools
import PIL.Image
from PIL.PngImagePlugin import PngImageFile
from PIL.ImageFile import ImageFile
from PIL import EpsImagePlugin
def init(**options):
# download ghostscript: https://www.ghostscript.com/download/gsdnld.html
if options.get('gs_windows_binary'):
EpsImagePlugin.gs_windows_binary = options['gs_windows_binary'] # install ghostscript, otherwise->{OSError} Unable to locate Ghostscript on paths
# https://anzeljg.github.io/rin2/book2/2405/docs/tkinter/cap-join-styles.html
# change the default style of the line that made of two connected line segments
tkinter.ROUND = tkinter.BUTT # default is ROUND # https://anzeljg.github.io/rin2/book2/2405/docs/tkinter/create_line.html
def make_gif(image_list: List[Path], output_path: Path, **options):
"""
:param image_list:
:param output_path:
:param options:
- fps: Frame Per Second. Duration and FPS, choose one to give.
- duration milliseconds (= 1000/FPS ) (default is 0.1 sec)
- loop # int, if 0, then loop forever. Otherwise, it means the loop number.
:return:
"""
if not output_path.parent.exists():
raise FileNotFoundError(output_path.parent)
if not output_path.name.lower().endswith('.gif'):
output_path = output_path / Path('.gif')
image_list: List[ImageFile] = [PIL.Image.open(str(_)) for _ in image_list]
im = image_list.pop(0)
fps = options.get('fps', options.get('FPS', 10))
im.save(output_path, format='gif', save_all=True, append_images=image_list,
duration=options.get('duration', int(1000 / fps)),
loop=options.get('loop', 0))
class GIFCreator:
__slots__ = ['draw',
'__temp_dir', '__duration',
'__name', '__is_running', '__counter', ]
TEMP_DIR = Path('.') / Path('__temp__for_gif')
# The time gap that you pick image after another on the recording. i.e., If the value is low, then you can get more source image, so your GIF has higher quality.
DURATION = 100 # millisecond. # 1000 / FPS
REBUILD = True
def __init__(self, name, temp_dir: Path = None, duration: int = None, **options):
self.__name = name
self.__is_running = False
self.__counter = 1
self.__temp_dir = temp_dir if temp_dir else self.TEMP_DIR
self.__duration = duration if duration else self.DURATION
if not self.__temp_dir.exists():
self.__temp_dir.mkdir(parents=True) # True, it's ok when parents is not exists
#property
def name(self):
return self.__name
#property
def duration(self):
return self.__duration
#property
def temp_dir(self):
if not self.__temp_dir.exists():
raise FileNotFoundError(self.__temp_dir)
return self.__temp_dir
def configure(self, **options):
gif_class_members = (_ for _ in dir(GIFCreator) if not _.startswith('_') and not callable(getattr(GIFCreator, _)))
for name, value in options.items():
name = name.upper()
if name not in gif_class_members:
raise KeyError(f"'{name}' does not belong to {GIFCreator} members.")
correct_type = type(getattr(self, name))
# type check
assert isinstance(value, correct_type), TypeError(f'{name} type need {correct_type.__name__} not {type(value).__name__}')
setattr(self, '_GIFCreator__' + name.lower(), value)
def record(self, draw_func: Callable = None, **options):
"""
:param draw_func:
:param options:
- fps
- start_after: milliseconds. While waiting, white pictures will continuously generate to used as the heading image of GIF.
- end_after:
:return:
"""
if draw_func and callable(draw_func):
setattr(self, 'draw', draw_func)
if not (hasattr(self, 'draw') and callable(getattr(self, 'draw'))):
raise NotImplementedError('subclasses of GIFCreatorMixin must provide a draw() method')
regex = re.compile(fr"""{self.name}_[0-9]{{4}}""")
def wrap():
self.draw()
turtle.ontimer(self._stop, options.get('end_after', 0))
wrap_draw = functools.wraps(self.draw)(wrap)
try:
# https://blog.csdn.net/lingyu_me/article/details/105400510
turtle.reset() # Does a turtle.clear() and then resets this turtle's state (i.e. direction, position etc.)
except turtle.Terminator:
turtle.reset()
if self.REBUILD:
for f in [_ for _ in self.temp_dir.glob(f'*.*') if _.suffix.upper().endswith(('EPS', 'PNG'))]:
[os.remove(f) for ls in regex.findall(str(f)) if ls is not None]
self._start()
self._save() # init start the recording
turtle.ontimer(wrap_draw,
t=options.get('start_after', 0)) # start immediately
turtle.done()
print('convert_eps2image...')
self.convert_eps2image()
print('make_gif...')
self.make_gif(fps=options.get('fps'))
print(f'done:{self.name}')
return
def convert_eps2image(self):
"""
image extension (PGM, PPM, GIF, PNG) is all compatible with tk.PhotoImage
.. important:: you need to use ghostscript, see ``init()``
"""
for eps_file in [_ for _ in self.temp_dir.glob('*.*') if _.name.startswith(self.__name) and _.suffix.upper() == '.EPS']:
output_path = self.temp_dir / Path(eps_file.name + '.png')
if output_path.exists():
continue
im: PIL.Image.Image = PIL.Image.open(str(eps_file))
im.save(output_path, 'png')
def make_gif(self, output_name=None, **options):
"""
:param output_name: basename `xxx.png` or `xxx`
:param options:
- fps: for GIF
:return:
"""
if output_name is None:
output_name = self.__name
if not output_name.lower().endswith('.gif'):
output_name += '.gif'
image_list = [_ for _ in self.temp_dir.glob(f'{self.__name}*.*') if
(_.suffix.upper().endswith(('PGM', 'PPM', 'GIF', 'PNG')) and _.name.startswith(self.__name))
]
if not image_list:
sys.stderr.write(f'There is no image on the directory. {self.temp_dir / Path(self.__name + "*.*")}')
return
output_path = Path('.') / Path(f'{output_name}')
fps = options.get('fps', options.get('FPS'))
if fps is None:
fps = 1000 / self.duration
make_gif(image_list, output_path,
fps=fps, loop=0)
os.startfile('.') # open the output folder
def _start(self):
self.__is_running = True
def _stop(self):
print(f'finished draw:{self.name}')
self.__is_running = False
self.__counter = 1
def _save(self):
if self.__is_running:
# print(self.__counter)
output_file: Path = self.temp_dir / Path(f'{self.__name}_{self.__counter:04d}.eps')
if not output_file.exists():
turtle.getcanvas().postscript(file=output_file) # 0001.eps, 0002.eps ...
self.__counter += 1
turtle.ontimer(self._save, t=self.duration) # trigger only once, so we need to set it again.
USAGE
init(gs_windows_binary=r'C:\Program Files\gs\gs9.52\bin\gswin64c')
def your_draw_function():
turtle.color("red")
turtle.width(20)
turtle.fd(40)
turtle.color("#00ffff")
turtle.bk(40)
...
# method 1: pass the draw function directly.
gif_demo = GIFCreator(name='demo')
# gif_demo.configure(duration=400) # Optional
gif_demo.record(your_draw_function)
# method 2: use class
# If you want to create a class, just define your draw function, and then record it.
class MyGIF(GIFCreator):
DURATION = 200 # optional
def draw(self):
your_draw_function()
MyGIF(name='rectangle demo').record(
# fps=, start_after=, end_after= <-- optional
)
demo
init(gs_windows_binary=r'C:\Program Files\gs\gs9.52\bin\gswin64c')
class TaiwanFlag(GIFCreator):
DURATION = 200
# REBUILD = False
def __init__(self, ratio, **kwargs):
"""
ratio: 0.5 (40*60) 1 (80*120) 2 (160*240) ...
"""
self.ratio = ratio
GIFCreator.__init__(self, **kwargs)
def show_size(self):
print(f'width:{self.ratio * 120}\nheight:{self.ratio * 80}')
#property
def size(self): # w, h
return self.ratio * 120, self.ratio * 80
def draw(self):
# from turtle import *
# turtle.tracer(False)
s = self.ratio # scale
pu()
s_w, s_h = turtle.window_width(), turtle.window_height()
margin_x = (s_w - self.size[0]) / 2
home_xy = -s_w / 2 + margin_x, 0
goto(home_xy)
pd()
color("red")
width(80 * s)
fd(120 * s)
pu()
goto(home_xy)
color('blue')
goto(home_xy[0], 20 * s)
width(40 * s)
pd()
fd(60 * s)
pu()
bk((30 + 15) * s)
pd()
color('white')
width(1)
left(15)
begin_fill()
for i in range(12):
fd(30 * s)
right(150)
end_fill()
rt(15)
pu()
fd(15 * s)
rt(90)
fd(8.5 * s)
pd()
lt(90)
# turtle.tracer(True)
begin_fill()
circle(8.5 * s)
end_fill()
color('blue')
width(2 * s)
circle(8.5 * s)
# turtle.tracer(True)
turtle.hideturtle()
taiwan_flag = TaiwanFlag(2, name='taiwan')
turtle.Screen().setup(taiwan_flag.size[0] + 40, taiwan_flag.size[1] + 40) # margin = 40
# taiwan_flag.draw()
taiwan_flag.record(end_after=2500, fps=10)
I am currently experimenting with the pytest module to create unit tests for a project I'm working on. I'm trying to test the 'add_point' method which draws an ellipse based on a set of pixels. What I want to do is inspect 'draw' to ensure that the ellipse has been created successfully. Unfortunately I don't know how to go about this, so any help will be appreciated. Here's my code so far:
(A) TheSlicePreviewMaker.py
import os, Image, ImageDraw, ImageFont
from json_importer import json_importer
class SlicePreviewer(object):
def __init__(self):
self.screen_size = (470, 470)
self.background_colour = (86,0,255)
self.platform_fill_colour = (100, 100, 100)
self.platform_outline_colour = (0, 0, 0)
self.platform_window = (0,0,469,469)
self.point_colour = (0,0,255)
self.config_object = json_importer("ConfigFile.txt")
self.image = None
def initialise_image(self):
self.image = Image.new('RGB',self.screen_size,self.background_colour)
draw = ImageDraw.Draw(self.image)
draw.rectangle(self.platform_window,outline=self.platform_outline_colour,fill=self.platform_fill_colour)
del draw
def add_point(self, px, py):
x1 = px - 1
y1 = py - 1
x2 = px + 1
y2 = py + 1
draw = ImageDraw.Draw(self.image)
draw.ellipse((x1,y1,x2,y2),outline=self.point_colour,fill=self.point_colour)
return draw #del draw
def save_image(self, file_name):
self.image.save(file_name, "BMP")
(B) test_TheSlicePreviewMaker.py
from TheSlicePreviewMaker import SlicePreviewer
slice_preview = SlicePreviewer()
class TestSlicePreviewer:
def test_init(self):
'''check that the config file object has been created on init'''
assert slice_preview.config_object != None
def test_initialise_image(self):
'''verify if the image has been successfully initialised'''
assert slice_preview.image.mode == 'RGB'
def test_add_point(self):
'''has the point been drawn successfully?'''
draw = slice_preview.add_point(196,273)
assert something
import pytest
if __name__ == '__main__':
pytest.main("--capture=sys -v")
SN: I've run TheSlicePreviewMaker.py separately to check the bitmap file it produces, so I know that the code works. What I want to achieve is unit test this so that each time I don't have to go check the bitmap.
One approach is to manually inspect the generated image and if looks OK to you, save it next to the test and use a image diffing algorithm (for example ImageChops.difference) to obtain a threshold value that you can use to make sure future test runs are still drawing the same image.
For example:
# contents of conftest.py
from PIL import ImageChops, ImageDraw, Image
import pytest
import os
import py.path
import math
import operator
def rms_diff(im1, im2):
"""Calculate the root-mean-square difference between two images
Taken from: http://snipplr.com/view/757/compare-two-pil-images-in-python/
"""
h1 = im1.histogram()
h2 = im2.histogram()
def mean_sqr(a,b):
if not a:
a = 0.0
if not b:
b = 0.0
return (a-b)**2
return math.sqrt(reduce(operator.add, map(mean_sqr, h1, h2))/(im1.size[0]*im1.size[1]))
class ImageDiff:
"""Fixture used to make sure code that generates images continues to do so
by checking the difference of the genereated image against known good versions.
"""
def __init__(self, request):
self.directory = py.path.local(request.node.fspath.dirname) / request.node.fspath.purebasename
self.expected_name = (request.node.name + '.png')
self.expected_filename = self.directory / self.expected_name
def check(self, im, max_threshold=0.0):
__tracebackhide__ = True
local = py.path.local(os.getcwd()) / self.expected_name
if not self.expected_filename.check(file=1):
msg = '\nExpecting image at %s, but it does not exist.\n'
msg += '-> Generating here: %s'
im.save(str(local))
pytest.fail(msg % (self.expected_filename, local))
else:
expected = Image.open(str(self.expected_filename))
rms_value = rms_diff(im, expected)
if rms_value > max_threshold:
im.save(str(local))
msg = '\nrms_value %s > max_threshold of %s.\n'
msg += 'Obtained image saved at %s'
pytest.fail(msg % (rms_value, max_threshold, str(local)))
#pytest.fixture
def image_diff(request):
return ImageDiff(request)
Now you can use the image_diff fixture in your tests. For example:
def create_image():
""" dummy code that generates an image, simulating some actual code """
im = Image.new('RGB', (100, 100), (0, 0, 0))
draw = ImageDraw.Draw(im)
draw.ellipse((10, 10, 90, 90), outline=(0, 0, 255),
fill=(255, 255, 255))
return im
def test_generated_image(image_diff):
im = create_image()
image_diff.check(im)
The first time your run this test, it will fail with this output:
================================== FAILURES ===================================
____________________________ test_generated_image _____________________________
image_diff = <test_foo.ImageDiff instance at 0x029ED530>
def test_generated_image(image_diff):
im = create_image()
> image_diff.check(im)
E Failed:
E Expecting image at X:\temp\sandbox\img-diff\test_foo\test_generated_image.png, but it does not exist.
E -> Generating here: X:\temp\sandbox\img-diff\test_generated_image.png
You can then manually check the image and if everything is OK, move it to a directory with the same name as the test file, with the name of the test as the file name plus ".png" extension. From now one whenever the test runs, it will check that the image is similar within an acceptable amount.
Suppose you change the code and produce a slightly different image, the test will now fail like this:
================================== FAILURES ===================================
____________________________ test_generated_image _____________________________
image_diff = <test_foo.ImageDiff instance at 0x02A4B788>
def test_generated_image(image_diff):
im = create_image()
> image_diff.check(im)
E Failed:
E rms_value 2.52 > max_threshold of 0.0.
E Obtained image saved at X:\temp\sandbox\img-diff\test_generated_image.png
test_foo.py:63: Failed
========================== 1 failed in 0.03 seconds ===========================
The code needs some polishing but should be a good start. You can find a version of this code here.
Cheers,