I have a stream of sensor data which I want to visualize in a plot with many subplots. Plotting the data is a real bottleneck in my code. Right now I get with small resolution only 16 FPS which is far too slow.
Here is what it looks like:
import time
from matplotlib import pyplot as plt
import numpy as np
def live_plot():
loops=100
n = 7
p = 30
fig, axes = plt.subplots(ncols=n, nrows=n)
fig.canvas.draw()
handles=[]
axes = np.array(axes)
for ax in axes.reshape(-1):
ax.axis("off")
handles.append(ax.imshow(np.random.rand(p,p), interpolation="None", cmap="RdBu"))
t0 = time.time()
for i in np.arange(loops):
for h in handles: h.set_data(np.random.rand(p,p))
for h, ax in zip(handles, axes.reshape(-1)): ax.draw_artist(h)
plt.pause(1e-12)
print("avg: " + "%.1f" % (loops/(time.time()-t0)) + " FPS")
live_plot()
What can I do to get more frames per second?
1 Answer 1
First notice that you're using essentially none of the features of matplotlib. There are no titles, axis labels or annotations of any kind. Your example assumes that imshow
is responsible for performing a color map of RdBu
, but let's ignore that and assume that your data are just as visible in greyscale.
The first step would be to use a proper matplotlib animation, but we're going for higher performance so let's cut under that.
You need to choose a balance between portability and performance. The highest-performing methods would use some C binding to the operating system graphics driver, but let's not go that far; instead, let's cut down to tkinter
which is available on most Python implementations. tkinter
is the default interactive backend for matplotlib. Reading site-packages/matplotlib/backends/_backend_tk.py
to see how it works,
class FigureCanvasTk(FigureCanvasBase):
required_interactive_framework = "tk"
manager_class = _api.classproperty(lambda cls: FigureManagerTk)
def __init__(self, figure=None, master=None):
super().__init__(figure)
self._idle_draw_id = None
self._event_loop_id = None
w, h = self.get_width_height(physical=True)
self._tkcanvas = tk.Canvas(
master=master, background="white",
width=w, height=h, borderwidth=0, highlightthickness=0)
self._tkphoto = tk.PhotoImage(
master=self._tkcanvas, width=w, height=h)
self._tkcanvas_image_region = self._tkcanvas.create_image(
w//2, h//2, image=self._tkphoto)
self._tkcanvas.bind("<Configure>", self.resize)
If we're to implement a simplified version, then we will want to replicate the use of tk.PhotoImage
. Plenty of online resources like "Is it possible to provide animation on image Tkinter" demonstrate how this may be done.
You could use a single PhotoImage
with copied square regions for each sensor separated by black pixel values, or you could use one PhotoImage
for each sensor. I demonstrate the latter.
#!/usr/bin/env python3
import itertools
import time
import tkinter
import typing
import numpy as np
import PIL.Image
import PIL.ImageTk
class Sensor:
"""
Fake: cycle between canned frames so that data generation does not contribute to frame duration.
OP assumed rand() in [0, 1), but offered no explanation for the actual sensor data.
PIL offers F, a 32-bit floating-point mode, but this is NOT over [0, 1), rather [0, 256).
So instead, for simplicity and due to a lack of information needing otherwise, use L:
8-bit greyscale.
"""
rand = np.random.default_rng(seed=0)
def __init__(self, p: int = 30) -> None:
self.p = p
n_frames = 10
frames = self.rand.integers(low=0, high=256, size=(n_frames, p, p), dtype=np.uint8)
self.read = itertools.cycle(frames).__next__
def read(self) -> np.ndarray[typing.Any, np.dtype[np.uint8]]: ...
class SensorDisplay:
def __init__(self, sensor: Sensor, parent: tkinter.Widget, i: int, j: int) -> None:
self.sensor = sensor
self.parent = parent
self.suffix = f'_{i}_{j}'
# Do not construct these until we get an initial <Configure>
self.image: PIL.ImageTk.PhotoImage | None = None
# PhotoImage is non-size-aware. We need a wrapper element like a Label or Canvas.
# matplotlib's tk backend uses Canvas, but Label seems simpler.
# https://tkdocs.com/pyref/label.html
self.label = tkinter.Label(parent, name='label' + self.suffix)
self.label.grid_configure(
row=i, column=j, padx=1, pady=1, sticky=tkinter.NSEW,
)
self.label.bind('<Configure>', func=self.on_resize)
def on_resize(self, event: tkinter.Event) -> None:
# https://pillow.readthedocs.io/en/stable/handbook/concepts.html#concept-modes
mode = 'L' # 8-bit integral greyscale
# PIL.ImageTk.PhotoImage wraps a tkinter.PhotoImage. Since it persists state like the size,
# we cannot only call self.image._PhotoImage__photo.configure(); we need to fully replace it.
# https://pillow.readthedocs.io/en/stable/reference/ImageTk.html#PIL.ImageTk.PhotoImage
# Cannot pass name='image'+self.suffix to kwargs because paste() will claim that
# "the desination photo doesn't exist"
self.image = PIL.ImageTk.PhotoImage(
mode, size=(event.width, event.height), master=self.parent,
)
self.label.configure(image=self.image)
def refresh(self) -> None:
memmap = PIL.Image.fromarray(obj=self.sensor.read(), mode='L')
resized = memmap.resize(
size=(self.image.width(), self.image.height()),
resample=PIL.Image.Resampling.NEAREST,
)
self.image.paste(resized)
def live_plot(n: int = 7, n_frames: int = 100) -> None:
root = tkinter.Tk()
root.geometry('800x600')
root.grid_rowconfigure(index=0, weight=1)
root.grid_columnconfigure(index=0, weight=1)
frame = tkinter.Frame(root, name='sensor_frame')
frame.grid_configure(row=0, column=0, sticky=tkinter.NSEW)
displays = [
SensorDisplay(Sensor(), parent=frame, i=i, j=j)
for i in range(n)
for j in range(n)
]
idx = tuple(range(n))
frame.grid_rowconfigure(index=idx, weight=1)
frame.grid_columnconfigure(index=idx, weight=1)
root.update()
start = time.perf_counter()
for _ in range(n_frames):
for display in displays:
display.refresh()
root.update()
fps = n_frames/(time.perf_counter() - start)
print(f'FPS: {fps:.1f} Hz')
if __name__ == '__main__':
live_plot()
FPS: 141.2 Hz
Explore related questions
See similar questions with these tags.
numpy
are fine to simulate sensor data and real data are probably not essential to make the code run faster. Please tell me if I am wrong. \$\endgroup\$