Don't import from math when you have Numpy.
I didn't find mpl_use to be necessary.
history, since it's a constant, should be HISTORY.
Don't [i for i in range(history)]; range(history) alone has the same effect since you don't need to mutate the result.
Add PEP484 type hints.
Expanding your colour thus:
r, g, b = colour
is not needed, since you can just use *colour in your list.
You never normalise so I've omitted this from my example code.
Your wrapping code can be greatly simplified by using a single call to np.mod.
From fig, ax = plt.subplots() onward, none of that code should exist in the global namespace and should be moved to functions.
The biggest factor causing apparent slowness is that you've not overridden the default interval=, so the animation appears choppy. A first pass that addresses some of the above:
import random
from typing import Iterator
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.collections import PathCollection
from matplotlib.colors import LinearSegmentedColormap, ListedColormap
HISTORY = 100 # trail size per mover.
HOME_X, HOME_Y = 50, 50
WINDOW_LIMITS = 100
class Mover:
VELOCITY = 0.75
WRAPPING = True
def __init__(self, colour: list[float], window_limits: int) -> None:
# colour = R G B tuple of floats between 0 (black) and 1 (full)
cmap_array = np.empty((HISTORY, 4))
cmap_array[:, :3] = colour
cmap_array[:, 3] = np.linspace(start=1, stop=0, num=HISTORY)
ccmap = LinearSegmentedColormap.from_list(name="", colors=cmap_array)
offsets = range(HISTORY)
self.mv = 0., 1. # starting unit vector.
self.mxy = [
[HOME_X] * HISTORY,
[HOME_Y] * HISTORY,
]
self.plt: PathCollection = plt.scatter(*self.mxy, c=offsets, cmap=ccmap)
self.w_limits = window_limits
self.steer(np.radians(random.uniform(-180, 180)))
def start(self) -> None:
x, y = self.mxy # copy most recent state.
x.insert(0, x[0])
y.insert(0, y[0])
if len(x) > HISTORY: # remove the oldest state if too long..
x.pop()
y.pop()
def steer(self, theta: float) -> None:
# theta is in radians
x, y = self.mv
cosa, sina = np.cos(theta), np.sin(theta)
u = x*cosa - y*sina # x1 = x0cos(θ) – y0sin(θ)
v = x*sina + y*cosa # y1 = x0sin(θ) + y0cos(θ)
self.mv = u, v
def transit(self) -> None:
u, v = self.mv
x, y = self.mxy
x[0] += self.VELOCITY * u
y[0] += self.VELOCITY * v
if self.WRAPPING:
self.wrap()
def wrap(self) -> None:
x, y = self.mxy
if x[0] > self.w_limits:
x[0] -= self.w_limits
if y[0] > self.w_limits:
y[0] -= self.w_limits
if x[0] < 0:
x[0] += self.w_limits
if y[0] < 0:
y[0] += self.w_limits
def update_plot(self) -> None:
self.plt.set_offsets(np.array(self.mxy).T)
def make_movers(colours: ListedColormap, mover_count: int = 50) -> Iterator[Mover]:
colour_indices = np.linspace(start=0, stop=colours.N-1, num=mover_count, dtype=int)
for c in colour_indices:
col = colours.colors[c]
yield Mover(col, WINDOW_LIMITS)
def main() -> None:
def init() -> list[plt.Artist]:
ax.set_xlim(0, WINDOW_LIMITS)
ax.set_ylim(0, WINDOW_LIMITS)
return artists
def animate(frame: int) -> list[plt.Artist]:
theta = np.radians(30)
for mov in movers:
mov.start()
mov.steer(random.uniform(-theta, theta))
mov.transit()
mov.update_plot()
return artists
fig, ax = plt.subplots()
movers = tuple(make_movers(plt.cm.turbo))
artists = [mv.plt for mv in movers]
ani = FuncAnimation(fig=fig, func=animate, init_func=init, blit=True, interval=10)
plt.show()
if __name__ == '__main__':
main()
A vectorised implementation is possible, but doesn't make much of a difference; the slowest part is matplotlib itself:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.collections import PathCollection
from matplotlib.colors import LinearSegmentedColormap, ListedColormap
from numpy.random import default_rng
HISTORY = 100 # trail size per mover.
HOME_X, HOME_Y = 50, 50
WINDOW_LIMITS = 100
VELOCITY = 0.75
rand = default_rng()
def start_movers(pos: np.ndarray) -> None:
# pos is n_movers * HISTORY * 2, newest first
# shift the data one row down, treating it as a queue
pos[:, 1:, :] = pos[:, :-1, :]
def steer_movers(vel: np.ndarray, theta: float) -> None:
# vel is n_movers * 2
x, y = vel.T
theta = rand.uniform(low=-theta, high=theta, size=vel.shape[0])
cosa, sina = np.cos(theta), np.sin(theta)
u = x*cosa - y*sina
v = x*sina + y*cosa
vel[:, 0] = u
vel[:, 1] = v
def transit_movers(pos: np.ndarray, vel: np.ndarray) -> None:
pos[:, 0, :] += VELOCITY * vel
def wrap_movers(pos: np.ndarray) -> None:
np.mod(pos, WINDOW_LIMITS, out=pos)
class Mover:
def __init__(self, cmap_array: np.ndarray, pos: np.ndarray) -> None:
self.pos = pos
ccmap = LinearSegmentedColormap.from_list(name="", colors=cmap_array)
self.plt: PathCollection = plt.scatter(*self.pos.T, c=range(HISTORY), cmap=ccmap)
def update_plot(self) -> None:
self.plt.set_offsets(self.pos)
def make_movers(
colours: ListedColormap,
mover_count: int = 50,
) -> tuple[
np.ndarray, # positions
np.ndarray, # velocities
list[Mover],
]:
pos = np.empty((mover_count, HISTORY, 2))
pos[..., 0] = HOME_X
pos[..., 1] = HOME_Y
vel = np.empty((mover_count, 2))
theta = rand.uniform(low=-np.pi, high=np.pi, size=mover_count)
vel[:, 0] = np.cos(theta)
vel[:, 1] = np.sin(theta)
colour_indices = np.linspace(start=0, stop=colours.N-1, num=mover_count, dtype=int)
cmap_array = np.empty((mover_count, HISTORY, 4))
cmap_array[:, :, :3] = np.array(colours.colors)[colour_indices, np.newaxis, :]
cmap_array[:, :, 3] = np.linspace(start=1, stop=0, num=HISTORY)
movers = []
for i_mover, cmap in enumerate(cmap_array):
movers.append(Mover(cmap_array=cmap, pos=pos[i_mover, ...]))
return pos, vel, movers
def main() -> None:
def init() -> list[plt.Artist]:
ax.set_xlim(0, WINDOW_LIMITS)
ax.set_ylim(0, WINDOW_LIMITS)
return artists
def animate(frame: int) -> list[plt.Artist]:
start_movers(pos)
steer_movers(vel, theta=np.radians(30))
transit_movers(pos, vel)
wrap_movers(pos)
for mov in movers:
mov.update_plot()
return artists
fig, ax = plt.subplots()
pos, vel, movers = make_movers(plt.cm.turbo)
artists = [mv.plt for mv in movers]
ani = FuncAnimation(fig=fig, func=animate, init_func=init, blit=True, interval=10)
plt.show()
if __name__ == '__main__':
main()
A different style of animation is possible where you don't cull old points based on your queueing code and instead disable blit and draw over your old data, but this quickly hits scalability limits once enough data build up:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.collections import PathCollection
from matplotlib.colors import LinearSegmentedColormap, ListedColormap
from numpy.random import default_rng
HOME_X, HOME_Y = 50, 50
WINDOW_LIMITS = 100
VELOCITY = 0.75
rand = default_rng()
def steer_movers(vel: np.ndarray, theta: float) -> None:
# vel is n_movers * 2
x, y = vel.T
theta = rand.uniform(low=-theta, high=theta, size=vel.shape[0])
cosa, sina = np.cos(theta), np.sin(theta)
u = x*cosa - y*sina
v = x*sina + y*cosa
vel[:, 0] = u
vel[:, 1] = v
def transit_movers(pos: np.ndarray, vel: np.ndarray) -> None:
pos += VELOCITY * vel
def wrap_movers(pos: np.ndarray) -> None:
np.mod(pos, WINDOW_LIMITS, out=pos)
def make_movers(
colours: ListedColormap,
mover_count: int = 50,
) -> tuple[
np.ndarray, # positions
np.ndarray, # velocities
LinearSegmentedColormap, # colour map, non-history-based
]:
pos = np.empty((mover_count, 2))
pos[:, 0] = HOME_X
pos[:, 1] = HOME_Y
vel = np.empty((mover_count, 2))
theta = rand.uniform(low=-np.pi, high=np.pi, size=mover_count)
vel[:, 0] = np.cos(theta)
vel[:, 1] = np.sin(theta)
colour_indices = np.linspace(start=0, stop=colours.N-1, num=mover_count, dtype=int)
cmap_array = np.ones((mover_count, 4))
cmap_array[:, :3] = np.array(colours.colors)[colour_indices, :]
cmap = LinearSegmentedColormap.from_list(name="", colors=cmap_array)
return pos, vel, cmap
def main() -> None:
def init() -> list[plt.Artist]:
ax.set_xlim(0, WINDOW_LIMITS)
ax.set_ylim(0, WINDOW_LIMITS)
return []
def animate(frame: int) -> list[plt.Artist]:
steer_movers(vel, theta=np.radians(30))
transit_movers(pos, vel)
wrap_movers(pos)
scatter: PathCollection = plt.scatter(
*pos.T,
c=range(pos.shape[0]),
cmap=cmap,
)
return [scatter]
fig, ax = plt.subplots()
pos, vel, cmap = make_movers(plt.cm.turbo)
ani = FuncAnimation(fig=fig, func=animate, init_func=init, blit=False, interval=10)
plt.show()
if __name__ == '__main__':
main()